Compare commits

..

96 commits

Author SHA1 Message Date
9cda832b82
fix: request scopes email and profile
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 22:02:23 +01:00
613a5f2643
feat: support email scope to retrieve oidc info
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 21:51:12 +01:00
3d4020cf27 Merge pull request 'Fix oidc for authentik' (#258) from fix-oidc-for-authentik into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #258
Reviewed-by: carla <carla@noreply.git.local-it.org>
2025-12-03 20:52:20 +01:00
e03693ada5
style: fix formatting
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 20:51:26 +01:00
f0391d3fef
fix: oidc with authentik not working
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-03 20:34:33 +01:00
702eebd110 Merge pull request 'Implement dropdown to show/hide columns in member overview closes #209' (#240) from feature/209_hide_field_dropdown into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #240
Reviewed-by: moritz <moritz@noreply.git.local-it.org>
2025-12-03 19:01:13 +01:00
5ae4450444
Merge branch 'main' into feature/209_hide_field_dropdown
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 18:58:40 +01:00
cf6a108049 refactor: DRY - use Mv.Constants.custom_field_prefix() instead of string literals
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 18:47:27 +01:00
fabfe64468 refactor: rename custom_fields/member_fields to extract_*_field_keys for clarity 2025-12-03 18:44:17 +01:00
6029920c3f refactor: cleanup dropdown_menu component (required attr, remove redundant defaults, fix checkbox) 2025-12-03 18:42:49 +01:00
6cf955b024 fix: get_from_cookie now correctly handles list return from get_req_header 2025-12-03 18:37:51 +01:00
217ed632fa fix: preserve paid_filter in URL when toggling field visibility 2025-12-03 18:36:13 +01:00
3b038d451d fix: use all_custom_fields in prepare_dynamic_cols
Allows users to enable globally hidden custom fields in the table view
2025-12-03 18:20:32 +01:00
ecc6522571 test: restore deleted tests with dynamic field visibility support
Adapts icon distribution test for dynamic fields, restores empty cell test
2025-12-03 18:13:30 +01:00
b9bd5882e7 i18n: fix German translations for field visibility dropdown
Remove fuzzy flags and add correct translations for Columns, None, Options, etc
2025-12-03 18:11:24 +01:00
690083bdf0 refactor: fix Credo warnings in field visibility modules
Use Enum.map_join and reduce nesting in format_custom_field_label
2025-12-03 18:10:13 +01:00
4bbba65038 fix: remove duplicate member_fields_visible assignment in mount/3
Removes dead code and fixes initialization to use FieldVisibility module
2025-12-03 18:09:08 +01:00
75e1fc8a3a fix: use all_custom_fields in handle_info(:field_toggled)
Fixes bug where globally hidden custom fields could not be enabled via dropdown
2025-12-03 18:07:37 +01:00
a68a15be6a Merge pull request 'Refactor admin UI structure' (#244) from refactor-admin-settings-ui into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #244
Reviewed-by: carla <carla@noreply.git.local-it.org>
2025-12-03 18:00:53 +01:00
8ce89a7227 formatting
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 17:44:01 +01:00
f5b67de870
Merge branch 'main' into feature/209_hide_field_dropdown
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-03 17:38:50 +01:00
188a6f667c Merge branch 'main' into refactor-admin-settings-ui
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-03 17:29:07 +01:00
a483c287b6 Merge pull request 'chore(deps): update dependency just to v1.43.1' (#248) from renovate/asdf-tool-versions into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #248
2025-12-03 16:36:37 +01:00
a12ca6b041 Merge pull request 'chore(deps): update renovate/renovate docker tag to v41.173' (#254) from renovate/renovate-renovate-41.x into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #254
2025-12-03 16:36:12 +01:00
ba5fc34d80
Move custom fields to global admin settings
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-03 16:32:40 +01:00
a92771ffca
Also remove "-" prefix from merge conflicts in .po files 2025-12-03 16:30:32 +01:00
80a06c3609
Add some missing translations
All checks were successful
continuous-integration/drone/push Build is passing
This reverts commit 5c8a44c388.
2025-12-03 16:28:17 +01:00
a0ce88f71b Merge pull request 'Don't write line numbers in gettext comments' (#251) from skip-gettext-comment-line-numbers into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #251
Reviewed-by: carla <carla@noreply.git.local-it.org>
2025-12-03 15:38:11 +01:00
5c8a44c388 Merge branch 'main' into skip-gettext-comment-line-numbers
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-03 15:37:47 +01:00
5c1a766e87 Merge pull request 'Visual hierarchy for fields in member view and edit form - closes #231' (#247) from feature/231_member_view_ui into main
Reviewed-on: #247
Reviewed-by: rafael <rafael@noreply.git.local-it.org>
2025-12-03 15:33:01 +01:00
Renovate Bot
6c935b7540 chore(deps): update renovate/renovate docker tag to v41.173
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-12-03 14:30:21 +00:00
2542bcf9e4
fix: improve gettext translations and deduplicate email formatting in member views
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-12-03 15:30:09 +01:00
ed961f7585
Redesign member view/edit UI with improved accessibility
- Group fields into Personal Data, Custom Fields, and Payment Data sections
- Fix WCAG AA contrast issues and semantic HTML (dt/dd in dl)
- Format mailto links with member name in href attribute
2025-12-03 15:29:29 +01:00
c17445975c Merge branch 'main' into feature/209_hide_field_dropdown
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-03 14:57:56 +01:00
c9678231f9 fix: hide paid column and add tests
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-12-03 14:56:39 +01:00
c3b33b55a5 chore: moved component and added mix_quiet to justfile 2025-12-03 14:56:01 +01:00
8d1d04fa05 feat: increased accessibility 2025-12-03 14:55:31 +01:00
064c0df701 updated tests and fix merge conflict results 2025-12-03 14:55:20 +01:00
94245cbc0f
Skip writing line numbers in gettext file comments
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-12-03 14:53:54 +01:00
82f1a65b85 Merge pull request 'Fix postgres errors when running tests' (#245) from fix-test-postgres-errors into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #245
Reviewed-by: moritz <moritz@noreply.git.local-it.org>
2025-12-03 14:51:08 +01:00
dfff2486b5
Fix postgres errors when running tests
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 14:47:58 +01:00
aaea6a01e2 Merge pull request 'Implement UI fields to mock payment concept closes #226' (#241) from feature/226_payment_mockup into main
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone Build is passing
Reviewed-on: #241
2025-12-03 14:35:14 +01:00
4057b2d631
Extend gettext conflict script to other conflict marker styles 2025-12-03 14:32:46 +01:00
cd1af5aff5 feat: Add contribution management mock-up pages
Add non-functional preview pages for Contribution Types, Settings, and Member Contribution Periods with German translations
2025-12-03 14:32:09 +01:00
8391122426 resolve review issues 2025-12-03 14:32:09 +01:00
a5aeef3e27 docs: payment concept 2025-12-03 14:32:09 +01:00
422cf37a1e Merge pull request 'Fix UI issues' (#242) from ui-fixes into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #242
Reviewed-by: simon <s.thiessen@local-it.org>
2025-12-03 14:30:13 +01:00
a10d42f1ed Merge pull request 'Add file_envs for secrets and allow passing database url via separate envs' (#246) from add-file-envs into main
Some checks reported errors
continuous-integration/drone/push Build was killed
Reviewed-on: #246
Reviewed-by: rafael <rafael@noreply.git.local-it.org>
2025-12-03 14:29:32 +01:00
d1bab1288c
Merge remote-tracking branch 'origin/main' into add-file-envs
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-12-03 14:29:04 +01:00
1623b63207
fix: resolve review comments
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-12-03 14:27:22 +01:00
e6c5a58c65
Show dates in european format
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-12-03 14:20:14 +01:00
ee414c9440
Hide OIDC ID and ID columns for users 2025-12-03 14:20:14 +01:00
366d4c104a
Prevent tables from growing the page horizontally 2025-12-03 14:20:14 +01:00
Renovate Bot
26a46d966a chore(deps): update dependency just to v1.43.1
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-03 13:15:33 +00:00
09c75212b2
chore: add remove-gettext-conflicts to Justfile
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 13:46:55 +01:00
ce15b8f59b
fix: mailto formatting
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 12:54:49 +01:00
f0613fe1e5 Merge branch 'main' into feature/209_hide_field_dropdown
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-03 12:52:12 +01:00
d8384098b4
chore: update prod-compose to use file-envs for secrets
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 12:38:24 +01:00
0cafdbafcd Merge pull request 'Fix mailto email formatting' (#243) from fix_mailto into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #243
2025-12-03 12:36:50 +01:00
ee094eec2f
feat: add file env support for secrets 2025-12-03 12:36:13 +01:00
125f9ae77b
fix: mailto formatting
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 10:14:57 +01:00
206e733511 fix: search
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-02 18:46:16 +01:00
a143c4e243 Merge pull request 'Check translations when linting' (#236) from lint-translations into main
Reviewed-on: #236
Reviewed-by: moritz <moritz@noreply.git.local-it.org>
2025-12-02 16:51:45 +01:00
b0c94234a9
chore: update gettext
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-02 16:46:07 +01:00
eedd24b93c
Truncate long entries in tables to prevent height changes 2025-12-02 16:33:15 +01:00
06ba50f05d
Fix translation "Bearbeite" -> "Bearbeiten" 2025-12-02 16:32:55 +01:00
780f5f61ea Check translations when linting
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-02 16:17:52 +01:00
ac2ad0a0d5 Merge pull request 'Implement filter for has_paid closes #227' (#237) from feature/227_payment_filter into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #237
2025-12-02 16:12:42 +01:00
875c422b7d
Fix missing search query socket assign in member index
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-02 16:04:07 +01:00
6d75766dba
fix: add ESC key support, security comment, and disable async tests
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-02 15:55:27 +01:00
354029c9cc
fix: add role=none to li elements in payment filter for ARIA compliance 2025-12-02 15:55:26 +01:00
671e6ce804
feat: add payment status filter and paid column to member list
Add PaymentFilterComponent dropdown and colored paid column. Filter supports URL bookmarking and combines with search/sort.
2025-12-02 15:55:23 +01:00
386b4c9e65 Merge pull request 'Don't show birthday field for default configurations closes #161' (#239) from feature/161_remove_birthday into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #239
Reviewed-by: rafael <rafael@noreply.git.local-it.org>
2025-12-02 15:48:59 +01:00
88c5f3dde0 Merge pull request 'Mark required fields in UI' (#235) from mark-required-fields into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #235
Reviewed-by: moritz <moritz@noreply.git.local-it.org>
2025-12-02 15:26:10 +01:00
a67a91cffa
Mark required fields in UI
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-02 15:23:44 +01:00
0fb43a0816 feat: adds field visibility dropdown live component
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-02 15:00:09 +01:00
45a9bc0cc0 tests: added tests 2025-12-02 14:59:38 +01:00
c8968636a8 feat: remove birth_date field from Member resource
All checks were successful
continuous-integration/drone/push Build is passing
Users who need birthday data can use custom fields instead.
Closes #161
2025-12-02 14:58:50 +01:00
40835f7a2d Merge pull request 'Implement setting to show/hide member fields technically closes #214' (#232) from feature/214_hide_memberfields into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #232
2025-12-02 14:33:08 +01:00
13f77b5c0a
Refactor column visibility logic
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-02 14:18:27 +01:00
dce2053ce7 formatting and refactor member fields constant 2025-12-02 14:17:53 +01:00
e81aecce48 feat: adds member visibility to live view 2025-12-02 14:17:04 +01:00
397cbde9d6 feat: adds member visibility settings 2025-12-02 14:16:02 +01:00
831149f463 chore: adds constant for member_fields 2025-12-02 14:16:02 +01:00
944b868478 tests: adds tests 2025-12-02 14:16:02 +01:00
d10f2ecc90 chore: adds migration for member field visibility 2025-12-02 14:16:02 +01:00
d757d1b9be Merge pull request 'Implement bulk functionality to copy email adresses closes #230' (#234) from feature/230_email_copy into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #234
Reviewed-by: carla <carla@noreply.git.local-it.org>
2025-12-02 12:13:36 +01:00
39d2cb7820 refactor: improve email copy with MapSet, RFC 5322 commas, and cond
All checks were successful
continuous-integration/drone/push Build is passing
Performance optimization, RFC-compliant separator, better tests
2025-12-02 12:10:59 +01:00
ba78a6ac7a feat: improve email copy UX with colored alerts and mailto button
All checks were successful
continuous-integration/drone/push Build is passing
- Green success alert for copied confirmation
- Blue info alert with BCC privacy tip
- Mailto button opens email program with BCC recipients
- Alerts stack vertically instead of overlapping
2025-12-02 11:42:11 +01:00
e2ace3d2a8 feat: add bulk email copy for selected members (#230)
All checks were successful
continuous-integration/drone/push Build is passing
Copy selected members' emails to clipboard in 'First Last <email>' format
2025-12-02 10:02:58 +01:00
d039e4bb7d formatting and refactor member fields constant
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-02 10:02:52 +01:00
7f0da693ee feat: adds member visibility to live view
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-02 09:23:37 +01:00
82e41916d2 feat: adds member visibility settings 2025-12-02 09:23:23 +01:00
a022d8cd02 chore: adds constant for member_fields 2025-12-02 09:22:49 +01:00
f24d4985fc tests: adds tests 2025-12-02 09:22:26 +01:00
cf957563bb chore: adds migration for member field visibility 2025-12-02 08:45:18 +01:00
77 changed files with 10309 additions and 1981 deletions

View file

@ -53,6 +53,8 @@ steps:
- mix hex.audit
# Provide hints for improving code quality
- mix credo
# Check that translations are up to date
- mix gettext.extract --check-up-to-date
- name: wait_for_postgres
image: docker.io/library/postgres:17.6
@ -164,7 +166,7 @@ environment:
steps:
- name: renovate
image: renovate/renovate:41.151
image: renovate/renovate:41.173
environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN:

3
.gitignore vendored
View file

@ -41,3 +41,6 @@ npm-debug.log
.env
.elixir_ls/
# Docker secrets directory (generated by `just init-secrets`)
/secrets/

View file

@ -1,3 +1,3 @@
elixir 1.18.3-otp-27
erlang 27.3.4
just 1.43.0
just 1.43.1

View file

@ -12,8 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- PostgreSQL trigram-based member search with typo tolerance
- WCAG 2.1 AA compliant autocomplete dropdown with ARIA support
- Bilingual UI (German/English) for member linking workflow
- **Bulk email copy feature** - Copy email addresses of selected members to clipboard (#230)
- Email format: "First Last <email>" with semicolon separator (compatible with email clients)
- CopyToClipboard JavaScript hook with fallback for older browsers
- Button shows count of visible selected members (respects search/filter)
- German/English translations
- Docker secrets support via `_FILE` environment variables for all sensitive configuration (SECRET_KEY_BASE, TOKEN_SIGNING_SECRET, OIDC_CLIENT_SECRET, DATABASE_URL, DATABASE_PASSWORD)
### Fixed
- Email validation false positive when linking user and member with identical emails (#168 Problem #4)
- Relationship data extraction from Ash manage_relationship during validation
- Copy button count now shows only visible selected members when filtering

View file

@ -1,4 +1,7 @@
set dotenv-load := true
set export := true
MIX_QUIET := "1"
run: install-dependencies start-database migrate-database seed-database
mix phx.server
@ -29,6 +32,7 @@ lint:
mix format --check-formatted
mix compile --warnings-as-errors
mix credo
mix gettext.extract --check-up-to-date
audit:
mix sobelow --config
@ -83,4 +87,33 @@ regen-migrations migration_name commit_hash='':
clean:
mix clean
rm -rf .elixir_ls
rm -rf _build
rm -rf _build
# Remove Git merge conflict markers from gettext files
remove-gettext-conflicts:
#!/usr/bin/env bash
set -euo pipefail
find priv/gettext -type f -exec sed -i '/^<<<<<<</d; /^=======$/d; /^>>>>>>>/d; /^%%%%%%%/d; /^++++++/d; s/^+//; s/^-//' {} \;
# Production environment commands
# ================================
# Initialize secrets directory with generated secrets (only if not exists)
init-prod-secrets:
#!/usr/bin/env bash
set -euo pipefail
if [ -d "secrets" ]; then
echo "Secrets directory already exists. Skipping generation."
exit 0
fi
echo "Creating secrets directory and generating secrets..."
mkdir -p secrets
mix phx.gen.secret > secrets/secret_key_base.txt
mix phx.gen.secret > secrets/token_signing_secret.txt
openssl rand -base64 32 | tr -d '\n' > secrets/db_password.txt
touch secrets/oidc_client_secret.txt
echo "Secrets generated in ./secrets/"
# Start production environment with Docker Compose
start-prod: init-prod-secrets
docker compose -f docker-compose.prod.yml up -d

View file

@ -45,7 +45,7 @@ Our philosophy: **software should help people spend less time on administration
- 🚧 Sorting & filtering
- 🚧 Roles & permissions (e.g. board, treasurer)
- ✅ Custom fields (flexible per club needs)
- ✅ SSO via OIDC (tested with Rauthy)
- ✅ SSO via OIDC (works with Authentik, Rauthy, Keycloak, etc.)
- 🚧 Self-service & online application
- 🚧 Accessibility, GDPR, usability improvements
- 🚧 Email sending
@ -147,7 +147,26 @@ Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance i
5. copy client secret to `.env` file
6. abort and run `just run` again
Now you can log in to Mila via OIDC!
Now you can log in to Mila via OIDC!
### OIDC with other providers (Authentik, Keycloak, etc.)
Mila works with any OIDC-compliant provider. The internal strategy is named `:rauthy`, but this is just a name — it works with any provider.
**Important:** The redirect URI must always end with `/auth/user/rauthy/callback`.
Example for Authentik:
1. Create an OAuth2/OpenID Provider in Authentik
2. Set the redirect URI to: `https://your-domain.com/auth/user/rauthy/callback`
3. Configure environment variables:
```bash
DOMAIN=your-domain.com # or PHX_HOST=your-domain.com
OIDC_CLIENT_ID=your-client-id
OIDC_BASE_URL=https://auth.example.com/application/o/your-app
OIDC_CLIENT_SECRET=your-client-secret # or use OIDC_CLIENT_SECRET_FILE
```
The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/callback` if not explicitly set.
## ⚙️ Configuration
@ -210,13 +229,20 @@ For testing the production Docker build locally:
# Required variables:
SECRET_KEY_BASE=<your-generated-secret>
TOKEN_SIGNING_SECRET=<your-generated-secret>
PHX_HOST=localhost
DOMAIN=localhost # or PHX_HOST=localhost
# Optional (have defaults in docker-compose.prod.yml):
# Optional OIDC configuration:
# OIDC_CLIENT_ID=mv
# OIDC_BASE_URL=http://localhost:8080/auth/v1
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
# OIDC_CLIENT_SECRET=<from-rauthy-client>
# OIDC_CLIENT_SECRET=<from-your-oidc-provider>
# OIDC_REDIRECT_URI is auto-generated as https://{DOMAIN}/auth/user/rauthy/callback
# Alternative: Use _FILE variables for Docker secrets (takes priority over regular vars):
# SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base
# TOKEN_SIGNING_SECRET_FILE=/run/secrets/token_signing_secret
# OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
# DATABASE_URL_FILE=/run/secrets/database_url
# DATABASE_PASSWORD_FILE=/run/secrets/database_password
```
3. **Start development environment** (for Rauthy):
@ -250,7 +276,7 @@ For actual production deployment:
- Set `OIDC_BASE_URL` to your production OIDC provider
- Configure proper Docker networks
3. **Set up SSL/TLS** (e.g., via reverse proxy like Nginx/Traefik)
4. **Use secure secrets management** (environment variables, Docker secrets, vault)
4. **Use secure secrets management** — All sensitive environment variables support a `_FILE` suffix for Docker secrets (e.g., `SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base`). See `docker-compose.prod.yml` for an example setup with Docker secrets.
5. **Configure database backups**

View file

@ -27,6 +27,33 @@ let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("
// Hooks for LiveView components
let Hooks = {}
// CopyToClipboard hook: Copies text to clipboard when triggered by server event
Hooks.CopyToClipboard = {
mounted() {
this.handleEvent("copy_to_clipboard", ({text}) => {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).catch(err => {
console.error("Clipboard write failed:", err)
})
} else {
// Fallback for older browsers
const textArea = document.createElement("textarea")
textArea.value = text
textArea.style.position = "fixed"
textArea.style.left = "-999999px"
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand("copy")
} catch (err) {
console.error("Fallback clipboard copy failed:", err)
}
document.body.removeChild(textArea)
}
})
}
}
// ComboBox hook: Prevents form submission when Enter is pressed in dropdown
Hooks.ComboBox = {
mounted() {
@ -43,46 +70,14 @@ Hooks.ComboBox = {
destroyed() {
this.el.removeEventListener("keydown", this.handleKeyDown)
},
};
// MemberSortPersistence hook: Persists sorting order to a cookie
Hooks.MemberSortPersistence = {
mounted() {
this.handleEvent("persist_sort", ({ sort_field, sort_order }) => {
const setCookie = (name, value) => {
const secure = window.location.protocol === "https:" ? "Secure" : "";
document.cookie = `${name}=${encodeURIComponent(
value
)}; path=/; SameSite=Lax; ${secure}`;
};
setCookie("member_sort_field", sort_field);
setCookie("member_sort_order", sort_order);
});
},
};
// Helper to read and decode cookie value
const getCookie = (name) => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return decodeURIComponent(parts.pop().split(";").shift());
}
return null;
};
}
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: () => {
return {
_csrf_token: csrfToken,
member_sort_field: getCookie("member_sort_field"),
member_sort_order: getCookie("member_sort_order"),
};
},
hooks: Hooks,
});
params: {_csrf_token: csrfToken},
hooks: Hooks
})
// Listen for custom events from LiveView
window.addEventListener("phx:set-input-value", (e) => {

View file

@ -7,6 +7,75 @@ import Config
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
# Helper function to read environment variables with Docker secrets support.
# Supports the _FILE suffix pattern: if VAR_FILE is set, reads the value from
# that file path. Otherwise falls back to VAR directly.
# VAR_FILE takes priority and must contain the full absolute path to the secret file.
get_env_or_file = fn var_name, default ->
file_var = "#{var_name}_FILE"
case System.get_env(file_var) do
nil ->
System.get_env(var_name, default)
file_path ->
case File.read(file_path) do
{:ok, content} ->
String.trim_trailing(content)
{:error, reason} ->
raise """
Failed to read secret from file specified in #{file_var}="#{file_path}".
Error: #{inspect(reason)}
"""
end
end
end
# Same as get_env_or_file but raises if the value is not set
get_env_or_file! = fn var_name, error_message ->
case get_env_or_file.(var_name, nil) do
nil -> raise error_message
value -> value
end
end
# Build database URL from individual components or use DATABASE_URL directly.
# Supports both approaches:
# 1. DATABASE_URL (or DATABASE_URL_FILE) - full connection URL
# 2. Separate vars: DATABASE_HOST, DATABASE_USER, DATABASE_PASSWORD (or _FILE), DATABASE_NAME, DATABASE_PORT
build_database_url = fn ->
case get_env_or_file.("DATABASE_URL", nil) do
nil ->
# Build URL from separate components
host =
System.get_env("DATABASE_HOST") ||
raise "DATABASE_HOST is required when DATABASE_URL is not set"
user =
System.get_env("DATABASE_USER") ||
raise "DATABASE_USER is required when DATABASE_URL is not set"
password =
get_env_or_file!.("DATABASE_PASSWORD", """
DATABASE_PASSWORD or DATABASE_PASSWORD_FILE is required when DATABASE_URL is not set.
""")
database =
System.get_env("DATABASE_NAME") ||
raise "DATABASE_NAME is required when DATABASE_URL is not set"
port = System.get_env("DATABASE_PORT", "5432")
# URL-encode the password to handle special characters
encoded_password = URI.encode_www_form(password)
"ecto://#{user}:#{encoded_password}@#{host}:#{port}/#{database}"
url ->
url
end
end
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
@ -21,12 +90,7 @@ if System.get_env("PHX_SERVER") do
end
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
database_url = build_database_url.()
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
@ -41,45 +105,72 @@ if config_env() == :prod do
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
# Supports SECRET_KEY_BASE or SECRET_KEY_BASE_FILE for Docker secrets.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
get_env_or_file!.("SECRET_KEY_BASE", """
environment variable SECRET_KEY_BASE (or SECRET_KEY_BASE_FILE) is missing.
You can generate one by calling: mix phx.gen.secret
""")
# PHX_HOST or DOMAIN can be used to set the host for the application.
# DOMAIN is commonly used in deployment environments (e.g., Portainer templates).
host =
System.get_env("PHX_HOST") ||
System.get_env("DOMAIN") ||
raise "Please define the PHX_HOST or DOMAIN environment variable."
host = System.get_env("PHX_HOST") || raise "Please define the PHX_HOST environment variable."
port = String.to_integer(System.get_env("PORT") || "4000")
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
# Rauthy OIDC configuration
# OIDC configuration (works with any OIDC provider: Authentik, Rauthy, Keycloak, etc.)
# Note: The strategy is named :rauthy internally, but works with any OIDC provider.
# The redirect_uri callback path is always /auth/user/rauthy/callback regardless of provider.
#
# Supports OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE for Docker secrets.
# OIDC_CLIENT_SECRET is required only if OIDC is being used (indicated by explicit OIDC env vars).
oidc_base_url = System.get_env("OIDC_BASE_URL")
oidc_client_id = System.get_env("OIDC_CLIENT_ID")
oidc_in_use = not is_nil(oidc_base_url) or not is_nil(oidc_client_id)
client_secret =
if oidc_in_use do
get_env_or_file!.("OIDC_CLIENT_SECRET", """
environment variable OIDC_CLIENT_SECRET (or OIDC_CLIENT_SECRET_FILE) is missing.
This is required when OIDC authentication is configured (OIDC_BASE_URL or OIDC_CLIENT_ID is set).
""")
else
get_env_or_file.("OIDC_CLIENT_SECRET", nil)
end
# Build redirect_uri: use OIDC_REDIRECT_URI if set, otherwise build from host.
# Uses HTTPS since production runs behind TLS termination.
default_redirect_uri = "https://#{host}/auth/user/rauthy/callback"
config :mv, :rauthy,
client_id: System.get_env("OIDC_CLIENT_ID") || "mv",
base_url: System.get_env("OIDC_BASE_URL") || "http://localhost:8080/auth/v1",
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
redirect_uri:
System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback"
client_id: oidc_client_id || "mv",
base_url: oidc_base_url || "http://localhost:8080/auth/v1",
client_secret: client_secret,
redirect_uri: System.get_env("OIDC_REDIRECT_URI") || default_redirect_uri
# Token signing secret from environment variable
# This overrides the placeholder value set in prod.exs
# Supports TOKEN_SIGNING_SECRET or TOKEN_SIGNING_SECRET_FILE for Docker secrets.
token_signing_secret =
System.get_env("TOKEN_SIGNING_SECRET") ||
raise """
environment variable TOKEN_SIGNING_SECRET is missing.
You can generate one by calling: mix phx.gen.secret
"""
get_env_or_file!.("TOKEN_SIGNING_SECRET", """
environment variable TOKEN_SIGNING_SECRET (or TOKEN_SIGNING_SECRET_FILE) is missing.
You can generate one by calling: mix phx.gen.secret
""")
config :mv, :token_signing_secret, token_signing_secret
config :mv, MvWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# Bind on all IPv4 interfaces.
# Use {0, 0, 0, 0, 0, 0, 0, 0} for IPv6, or {127, 0, 0, 1} for localhost only.
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0},
ip: {0, 0, 0, 0},
port: port
],
secret_key_base: secret_key_base,

View file

@ -45,3 +45,6 @@ config :mv, :token_signing_secret, "test_secret_key_for_ash_authentication_token
config :mv, :session_identifier, :unsafe
config :mv, :require_token_presence_for_authentication, false
# Enable SQL Sandbox for async LiveView tests
config :mv, :sql_sandbox, true

View file

@ -2,21 +2,32 @@ services:
app:
image: git.local-it.org/local-it/mitgliederverwaltung:latest
container_name: mv-prod-app
# Use host network for local testing to access localhost:8080 (Rauthy)
# In real production, remove this and use external OIDC provider
network_mode: host
ports:
- "4001:4001"
environment:
DATABASE_URL: "ecto://postgres:postgres@localhost:5001/mv_prod"
SECRET_KEY_BASE: "${SECRET_KEY_BASE}"
TOKEN_SIGNING_SECRET: "${TOKEN_SIGNING_SECRET}"
PHX_HOST: "${PHX_HOST}"
# Database configuration using separate variables
# Use Docker service name for internal networking
DATABASE_HOST: "db-prod"
DATABASE_PORT: "5432"
DATABASE_USER: "postgres"
DATABASE_NAME: "mv_prod"
DATABASE_PASSWORD_FILE: "/run/secrets/db_password"
# Phoenix secrets via Docker secrets
SECRET_KEY_BASE_FILE: "/run/secrets/secret_key_base"
TOKEN_SIGNING_SECRET_FILE: "/run/secrets/token_signing_secret"
PHX_HOST: "${PHX_HOST:-localhost}"
PORT: "4001"
PHX_SERVER: "true"
# Rauthy OIDC config - uses localhost because of host network mode
# Rauthy OIDC config - use host.docker.internal to reach host services
OIDC_CLIENT_ID: "mv"
OIDC_BASE_URL: "http://localhost:8080/auth/v1"
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1"
OIDC_CLIENT_SECRET_FILE: "/run/secrets/oidc_client_secret"
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
secrets:
- db_password
- secret_key_base
- token_signing_secret
- oidc_client_secret
depends_on:
- db-prod
restart: unless-stopped
@ -26,13 +37,25 @@ services:
container_name: mv-prod-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: mv_prod
secrets:
- db_password
volumes:
- postgres_data_prod:/var/lib/postgresql/data
ports:
- "5001:5432"
restart: unless-stopped
secrets:
db_password:
file: ./secrets/db_password.txt
secret_key_base:
file: ./secrets/secret_key_base.txt
token_signing_secret:
file: ./secrets/token_signing_secret.txt
oidc_client_secret:
file: ./secrets/oidc_client_secret.txt
volumes:
postgres_data_prod:

View file

@ -0,0 +1,653 @@
# Membership Contributions - Technical Architecture
**Project:** Mila - Membership Management System
**Feature:** Membership Contribution Management
**Version:** 1.0
**Last Updated:** 2025-11-27
**Status:** Architecture Design - Ready for Implementation
---
## Purpose
This document defines the technical architecture for the Membership Contributions system. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details.
**Related Documents:**
- [contributions-overview.md](./contributions-overview.md) - Business logic and requirements
- [database-schema-readme.md](./database-schema-readme.md) - Database documentation
- [database_schema.dbml](./database_schema.dbml) - Database schema definition
---
## Table of Contents
1. [Architecture Principles](#architecture-principles)
2. [Domain Structure](#domain-structure)
3. [Data Architecture](#data-architecture)
4. [Business Logic Architecture](#business-logic-architecture)
5. [Integration Points](#integration-points)
6. [Acceptance Criteria](#acceptance-criteria)
7. [Testing Strategy](#testing-strategy)
8. [Security Considerations](#security-considerations)
9. [Performance Considerations](#performance-considerations)
---
## Architecture Principles
### Core Design Decisions
1. **Single Responsibility:**
- Each module has one clear responsibility
- Period generation separated from status management
- Calendar logic isolated in dedicated module
2. **No Redundancy:**
- No `period_end` field (calculated from `period_start` + `interval`)
- No `interval_type` field (read from `contribution_type.interval`)
- Eliminates data inconsistencies
3. **Immutability Where Important:**
- `contribution_type.interval` cannot be changed after creation
- Prevents complex migration scenarios
- Enforced via Ash change validation
4. **Historical Accuracy:**
- `amount` stored per period for audit trail
- Enables tracking of contribution changes over time
- Old periods retain original amounts
5. **Calendar-Based Periods:**
- All periods aligned to calendar boundaries
- Simplifies date calculations
- Predictable period generation
---
## Domain Structure
### Ash Domain: `Mv.Contributions`
**Purpose:** Encapsulates all contribution-related resources and logic
**Resources:**
- `ContributionType` - Contribution type definitions (admin-managed)
- `ContributionPeriod` - Individual contribution periods per member
**Extensions:**
- Member resource extended with contribution fields
### Module Organization
```
lib/
├── contributions/
│ ├── contributions.ex # Ash domain definition
│ ├── contribution_type.ex # ContributionType resource
│ ├── contribution_period.ex # ContributionPeriod resource
│ └── changes/
│ ├── prevent_interval_change.ex # Validates interval immutability
│ ├── set_contribution_start_date.ex # Auto-sets start date
│ └── validate_same_interval.ex # Validates interval match on type change
├── mv/
│ └── contributions/
│ ├── period_generator.ex # Period generation algorithm
│ └── calendar_periods.ex # Calendar period calculations
└── membership/
└── member.ex # Extended with contribution relationships
```
### Separation of Concerns
**Domain Layer (Ash Resources):**
- Data validation
- Relationship management
- Policy enforcement
- Action definitions
**Business Logic Layer (`Mv.Contributions`):**
- Period generation algorithm
- Calendar calculations
- Date boundary handling
- Status transitions
**UI Layer (LiveView):**
- User interaction
- Display logic
- Authorization checks
- Form handling
---
## Data Architecture
### Database Schema Extensions
**See:** [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema documentation.
### New Tables
1. **`contribution_types`**
- Purpose: Define contribution types with fixed intervals
- Key Constraint: `interval` field immutable after creation
- Relationships: has_many members, has_many contribution_periods
2. **`contribution_periods`**
- Purpose: Individual contribution periods for members
- Key Design: NO `period_end` or `interval_type` fields (calculated)
- Relationships: belongs_to member, belongs_to contribution_type
- Composite uniqueness: One period per member per period_start
### Member Table Extensions
**Fields Added:**
- `contribution_type_id` (FK, NOT NULL with default from settings)
- `contribution_start_date` (Date, nullable)
**Existing Fields Used:**
- `joined_at` - For calculating contribution start
- `left_at` - For limiting period generation
- These fields must remain member fields and should not be replaced by custom fields in the future
### Settings Integration
**Global Settings:**
- `contributions.include_joining_period` (Boolean)
- `contributions.default_contribution_type_id` (UUID)
**Storage:** Existing settings mechanism (TBD: dedicated table or configuration resource)
### Foreign Key Behaviors
| Relationship | On Delete | Rationale |
|--------------|-----------|-----------|
| `contribution_periods.member_id → members.id` | CASCADE | Remove periods when member deleted |
| `contribution_periods.contribution_type_id → contribution_types.id` | RESTRICT | Prevent type deletion if periods exist |
| `members.contribution_type_id → contribution_types.id` | RESTRICT | Prevent type deletion if assigned to members |
---
## Business Logic Architecture
### Period Generation System
**Component:** `Mv.Contributions.PeriodGenerator`
**Responsibilities:**
- Calculate which periods should exist for a member
- Generate missing periods
- Respect contribution_start_date and left_at boundaries
- Skip existing periods (idempotent)
**Triggers:**
1. Member contribution type assigned (via Ash change)
2. Member created with contribution type (via Ash change)
3. Scheduled job runs (daily/weekly cron)
4. Admin manual regeneration (UI action)
**Algorithm Steps:**
1. Retrieve member with contribution_type and dates
2. Determine first period start (based on contribution_start_date)
3. Calculate all period starts from first to today (or left_at)
4. Query existing periods for member
5. Generate missing periods with current contribution_type.amount
6. Insert new periods (batch operation)
**Edge Case Handling:**
- If contribution_start_date is NULL: Calculate from joined_at + global setting
- If left_at is set: Stop generation at left_at
- If contribution_type changes: Handled separately by regeneration logic
### Calendar Period Calculations
**Component:** `Mv.Contributions.CalendarPeriods`
**Responsibilities:**
- Calculate period boundaries based on interval type
- Determine current period
- Determine last completed period
- Calculate period_end from period_start + interval
**Functions (high-level):**
- `calculate_period_start/3` - Given date and interval, find period start
- `calculate_period_end/2` - Given period_start and interval, calculate end
- `next_period_start/2` - Given period_start and interval, find next
- `is_current_period?/2` - Check if period contains today
- `is_last_completed_period?/2` - Check if period just ended
**Interval Logic:**
- **Monthly:** Start = 1st of month, End = last day of month
- **Quarterly:** Start = 1st of quarter (Jan/Apr/Jul/Oct), End = last day of quarter
- **Half-yearly:** Start = 1st of half (Jan/Jul), End = last day of half
- **Yearly:** Start = Jan 1st, End = Dec 31st
### Status Management
**Component:** Ash actions on `ContributionPeriod`
**Status Transitions:**
- Simple state machine: unpaid ↔ paid ↔ suspended
- No complex validation (all transitions allowed)
- Permissions checked via Ash policies
**Actions Required:**
- `mark_as_paid` - Set status to :paid
- `mark_as_suspended` - Set status to :suspended
- `mark_as_unpaid` - Set status to :unpaid (error correction)
**Bulk Operations:**
- `bulk_mark_as_paid` - Mark multiple periods as paid (efficiency)
- low priority, can be a future issue
### Contribution Type Change Handling
**Component:** Ash change on `Member.contribution_type_id`
**Validation:**
- Check if new type has same interval as old type
- If different: Reject change (MVP constraint)
- If same: Allow change
**Side Effects on Allowed Change:**
1. Keep all existing periods unchanged
2. Find future unpaid periods
3. Delete future unpaid periods
4. Regenerate periods with new contribution_type_id and amount
**Implementation Pattern:**
- Use Ash change module to validate
- Use after_action hook to trigger regeneration
- Use transaction to ensure atomicity
---
## Integration Points
### Member Resource Integration
**Extension Points:**
1. Add fields via migration
2. Add relationships (belongs_to, has_many)
3. Add calculations (current_period_status, overdue_count)
4. Add changes (auto-set contribution_start_date, validate interval)
**Backward Compatibility:**
- New fields nullable or with defaults
- Existing members get default contribution type from settings
- No breaking changes to existing member functionality
### Settings System Integration
**Requirements:**
- Store two global settings
- Provide UI for admin to modify
- Default values if not set
- Validation (e.g., default_contribution_type_id must exist)
**Access Pattern:**
- Read settings during period generation
- Read settings during member creation
- Write settings only via admin UI
### Permission System Integration
**See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)
**Required Permissions:**
- `ContributionType.create/update/destroy` - Admin only
- `ContributionType.read` - Admin, Treasurer, Board
- `ContributionPeriod.update` (status changes) - Admin, Treasurer
- `ContributionPeriod.read` - Admin, Treasurer, Board, Own member
**Policy Patterns:**
- Use existing HasPermission check
- Leverage existing roles (Admin, Kassenwart)
- Member can read own periods (linked via member_id)
### LiveView Integration
**New LiveViews Required:**
1. ContributionType index/form (admin)
2. ContributionPeriod table component (member detail view)
3. Settings form section (admin)
4. Member list column (contribution status)
**Existing LiveViews to Extend:**
- Member detail view: Add contributions section
- Member list view: Add status column
- Settings page: Add contributions section
**Authorization Helpers:**
- Use existing `can?/3` helper for UI conditionals
- Check permissions before showing actions
---
## Acceptance Criteria
### ContributionType Resource
**AC-CT-1:** Admin can create contribution type with name, amount, interval, description
**AC-CT-2:** Interval field is immutable after creation (validation error on change attempt)
**AC-CT-3:** Admin can update name, amount, description (but not interval)
**AC-CT-4:** Cannot delete contribution type if assigned to members
**AC-CT-5:** Cannot delete contribution type if periods exist referencing it
**AC-CT-6:** Interval must be one of: monthly, quarterly, half_yearly, yearly
### ContributionPeriod Resource
**AC-CP-1:** Period has period_start, status, amount, notes, member_id, contribution_type_id
**AC-CP-2:** Period_end is calculated, not stored
**AC-CP-3:** Status defaults to :unpaid
**AC-CP-4:** One period per member per period_start (uniqueness constraint)
**AC-CP-5:** Amount is set at generation time from contribution_type.amount
**AC-CP-6:** Periods cascade delete when member deleted
**AC-CP-7:** Admin/Treasurer can change status
**AC-CP-8:** Member can read own periods
### Member Extensions
**AC-M-1:** Member has contribution_type_id field (NOT NULL with default)
**AC-M-2:** Member has contribution_start_date field (nullable)
**AC-M-3:** New members get default contribution type from global setting
**AC-M-4:** contribution_start_date auto-set based on joined_at and global setting
**AC-M-5:** Admin can manually override contribution_start_date
**AC-M-6:** Cannot change to contribution type with different interval (MVP)
### Period Generation
**AC-PG-1:** Periods generated when member gets contribution type
**AC-PG-2:** Periods generated when member created (via change hook)
**AC-PG-3:** Scheduled job generates missing periods daily
**AC-PG-4:** Generation respects contribution_start_date
**AC-PG-5:** Generation stops at left_at if member exited
**AC-PG-6:** Generation is idempotent (skips existing periods)
**AC-PG-7:** Periods align to calendar boundaries (1st of month/quarter/half/year)
**AC-PG-8:** Amount comes from contribution_type at generation time
### Calendar Logic
**AC-CL-1:** Monthly periods: 1st to last day of month
**AC-CL-2:** Quarterly periods: 1st of Jan/Apr/Jul/Oct to last day of quarter
**AC-CL-3:** Half-yearly periods: 1st of Jan/Jul to last day of half
**AC-CL-4:** Yearly periods: Jan 1 to Dec 31
**AC-CL-5:** Period_end calculated correctly for all interval types
**AC-CL-6:** Current period determined correctly based on today's date
**AC-CL-7:** Last completed period determined correctly
### Contribution Type Change
**AC-TC-1:** Can change to type with same interval
**AC-TC-2:** Cannot change to type with different interval (error message)
**AC-TC-3:** On allowed change: future unpaid periods regenerated
**AC-TC-4:** On allowed change: paid/suspended periods unchanged
**AC-TC-5:** On allowed change: amount updated to new type's amount
**AC-TC-6:** Change is atomic (transaction)
### Settings
**AC-S-1:** Global setting: include_joining_period (boolean, default true)
**AC-S-2:** Global setting: default_contribution_type_id (UUID, required)
**AC-S-3:** Admin can modify settings via UI
**AC-S-4:** Settings validated (e.g., default type must exist)
**AC-S-5:** Settings applied to new members immediately
### UI - Member List
**AC-UI-ML-1:** New column shows contribution status
**AC-UI-ML-2:** Default: Shows last completed period status
**AC-UI-ML-3:** Optional: Toggle to show current period status
**AC-UI-ML-4:** Color coding: green (paid), red (unpaid), gray (suspended)
**AC-UI-ML-5:** Filter: Unpaid in last period
**AC-UI-ML-6:** Filter: Unpaid in current period
### UI - Member Detail
**AC-UI-MD-1:** Contributions section shows all periods
**AC-UI-MD-2:** Table columns: Period, Interval, Amount, Status, Actions
**AC-UI-MD-3:** Checkbox per period for bulk marking (low prio)
**AC-UI-MD-4:** "Mark selected as paid" button
**AC-UI-MD-5:** Dropdown to change contribution type (same interval only)
**AC-UI-MD-6:** Warning if different interval selected
**AC-UI-MD-7:** Only show actions if user has permission
### UI - Contribution Types Admin
**AC-UI-CTA-1:** List all contribution types
**AC-UI-CTA-2:** Show: Name, Amount, Interval, Member count
**AC-UI-CTA-3:** Create new contribution type form
**AC-UI-CTA-4:** Edit form: Name, Amount, Description editable
**AC-UI-CTA-5:** Edit form: Interval grayed out (not editable)
**AC-UI-CTA-6:** Warning on amount change (explain impact)
**AC-UI-CTA-7:** Cannot delete if members assigned
**AC-UI-CTA-8:** Only admin can access
### UI - Settings Admin
**AC-UI-SA-1:** Contributions section in settings
**AC-UI-SA-2:** Dropdown to select default contribution type
**AC-UI-SA-3:** Checkbox: Include joining period
**AC-UI-SA-4:** Explanatory text with examples
**AC-UI-SA-5:** Save button with validation
---
## Testing Strategy
### Unit Testing
**Period Generator Tests:**
- Correct period_start calculation for all interval types
- Correct period count from start to end date
- Respects contribution_start_date boundary
- Respects left_at boundary
- Skips existing periods (idempotent)
- Handles edge dates (year boundaries, leap years)
**Calendar Periods Tests:**
- Period boundaries correct for all intervals
- Period_end calculation correct
- Current period detection
- Last completed period detection
- Next period calculation
**Validation Tests:**
- Interval immutability enforced
- Same interval validation on type change
- Status transitions allowed
- Uniqueness constraints enforced
### Integration Testing
**Period Generation Flow:**
- Member creation triggers generation
- Type assignment triggers generation
- Type change regenerates future periods
- Scheduled job generates missing periods
- Left member stops generation
**Status Management Flow:**
- Mark single period as paid
- Bulk mark multiple periods (low prio)
- Status transitions work
- Permissions enforced
**Contribution Type Management:**
- Create type
- Update amount (regeneration triggered)
- Cannot update interval
- Cannot delete if in use
### LiveView Testing
**Member List:**
- Status column displays correctly
- Toggle between last/current works
- Filters work correctly
- Color coding applied
**Member Detail:**
- Periods table displays all periods
- Checkboxes work
- Bulk marking works (low prio)
- Type change validation works
- Actions only shown with permission
**Admin UI:**
- Type CRUD works
- Settings save correctly
- Validations display errors
- Only authorized users can access
### Edge Case Testing
**Interval Change Attempt:**
- Error message displayed
- No data modified
- User can cancel/choose different type
**Exit with Unpaid:**
- Warning shown
- Option to suspend offered
- Exit completes correctly
**Amount Change:**
- Warning displayed
- Only future unpaid regenerated
- Historical periods unchanged
**Date Boundaries:**
- Today = period start handled
- Today = period end handled
- Leap year handled
### Performance Testing
**Period Generation:**
- Generate 10 years of monthly periods: < 100ms
- Generate for 1000 members: < 5 seconds
- Idempotent check efficient (no full scan)
**Member List Query:**
- With status column: < 200ms for 1000 members
- Filters applied efficiently
- No N+1 queries
---
## Security Considerations
### Authorization
**Permissions Required:**
- ContributionType management: Admin only
- ContributionPeriod status changes: Admin + Treasurer
- View all periods: Admin + Treasurer + Board
- View own periods: All authenticated users
**Policy Enforcement:**
- All actions protected by Ash policies
- UI shows/hides based on permissions
- Backend validates permissions (never trust UI alone)
### Data Integrity
**Validation Layers:**
1. Database constraints (NOT NULL, UNIQUE, CHECK)
2. Ash validations (business rules)
3. UI validations (user experience)
**Immutability Protection:**
- Interval change prevented at multiple layers
- Period amounts immutable (audit trail)
- Settings changes logged (future)
### Audit Trail
**Tracked Information:**
- Period status changes (who, when) - future enhancement
- Type amount changes (implicit via period amounts)
- Member type assignments (via timestamps)
---
## Performance Considerations
### Database Indexes
**Required Indexes:**
- `contribution_periods(member_id)` - For member period lookups
- `contribution_periods(contribution_type_id)` - For type queries
- `contribution_periods(status)` - For unpaid filters
- `contribution_periods(period_start)` - For date range queries
- `contribution_periods(member_id, period_start)` - Composite unique index
- `members(contribution_type_id)` - For type membership count
### Query Optimization
**Preloading:**
- Load contribution_type with periods (avoid N+1)
- Load periods when displaying member detail
- Use Ash's load for efficient preloading
**Calculated Fields:**
- period_end calculated on-demand (not stored)
- current_period_status calculated when needed
- Use Ash calculations for lazy evaluation
**Pagination:**
- Period list paginated if > 50 periods
- Member list already paginated
### Caching Strategy
**No caching needed in MVP:**
- Contribution types rarely change
- Period queries are fast
- Settings read infrequently
**Future caching if needed:**
- Cache settings in application memory
- Cache contribution types list
- Invalidate on change
### Scheduled Job Performance
**Period Generation Job:**
- Run daily or weekly (not hourly)
- Batch members (process 100 at a time)
- Skip members with no changes
- Log failures for retry
---
## Future Enhancements
### Phase 2: Interval Change Support
**Architecture Changes:**
- Add logic to handle period overlaps
- Calculate prorata amounts if needed
- More complex validation
- Migration path for existing periods
### Phase 3: Payment Details
**Architecture Changes:**
- Add PaymentTransaction resource
- Link transactions to periods
- Support multiple payments per period
- Reconciliation logic
### Phase 4: vereinfacht.digital Integration
**Architecture Changes:**
- External API client module
- Webhook handling for transactions
- Automatic matching logic
- Manual review interface
---
**End of Architecture Document**

View file

@ -0,0 +1,527 @@
# Membership Contributions - Overview
**Project:** Mila - Membership Management System
**Feature:** Membership Contribution Management
**Version:** 1.0
**Last Updated:** 2025-11-27
**Status:** Concept - Ready for Review
---
## Purpose
This document provides a comprehensive overview of the Membership Contributions system. It covers business logic, data model, UI/UX design, and technical architecture in a concise, bullet-point format.
**For detailed implementation:** See [contributions-implementation-plan.md](./contributions-implementation-plan.md) (created after concept iterations)
---
## Table of Contents
1. [Core Principle](#core-principle)
2. [Terminology](#terminology)
3. [Data Model](#data-model)
4. [Business Logic](#business-logic)
5. [UI/UX Design](#uiux-design)
6. [Edge Cases](#edge-cases)
7. [Technical Integration](#technical-integration)
8. [Implementation Scope](#implementation-scope)
---
## Core Principle
**Maximum Simplicity:**
- Minimal complexity
- Clear data model without redundancies
- Intuitive operation
- Calendar period-based (Month/Quarter/Half-Year/Year)
---
## Terminology
### German ↔ English
**Core Entities:**
- Beitragsart ↔ Contribution Type / Membership Fee Type
- Beitragsintervall ↔ Contribution Period
- Mitgliedsbeitrag ↔ Membership Fee / Contribution
**Status:**
- bezahlt ↔ paid
- unbezahlt ↔ unpaid
- ausgesetzt ↔ suspended / waived
**Intervals:**
- monatlich ↔ monthly
- quartalsweise ↔ quarterly
- halbjährlich ↔ half-yearly / semi-annually
- jährlich ↔ yearly / annually
**UI Elements:**
- "Letztes Intervall" ↔ "Last Period" (e.g., 2023 when in 2024)
- "Aktuelles Intervall" ↔ "Current Period" (e.g., 2024)
- "Als bezahlt markieren" ↔ "Mark as paid"
- "Aussetzen" ↔ "Suspend" / "Waive"
---
## Data Model
### Contribution Type (ContributionType)
```
- id (UUID)
- name (String) - e.g., "Regular", "Reduced", "Student"
- amount (Decimal) - Contribution amount in Euro
- interval (Enum) - :monthly, :quarterly, :half_yearly, :yearly
- description (Text, optional)
- timestamps
```
**Important:**
- `interval` is **IMMUTABLE** after creation!
- Admin can only change `name`, `amount`, `description`
- On change: Future unpaid periods regenerated with new amount
### Contribution Period (ContributionPeriod)
```
- id (UUID)
- member_id (FK → members.id)
- contribution_type_id (FK → contribution_types.id)
- period_start (Date) - Calendar period start (01.01., 01.04., 01.07., 01.10., etc.)
- status (Enum) - :unpaid (default), :paid, :suspended
- amount (Decimal) - Amount at generation time (history when type changes)
- notes (Text, optional) - Admin notes
- timestamps
```
**Important:**
- **NO** `period_end` - calculated from `period_start` + `interval`
- **NO** `interval_type` - read from `contribution_type.interval`
- Avoids redundancy and inconsistencies!
**Calendar Period Logic:**
- Monthly: 01.01. - 31.01., 01.02. - 28./29.02., etc.
- Quarterly: 01.01. - 31.03., 01.04. - 30.06., 01.07. - 30.09., 01.10. - 31.12.
- Half-yearly: 01.01. - 30.06., 01.07. - 31.12.
- Yearly: 01.01. - 31.12.
### Member (Extensions)
```
- contribution_type_id (FK → contribution_types.id, NOT NULL, default from settings)
- contribution_start_date (Date, nullable) - When to start generating contributions
- left_at (Date, nullable) - Exit date (existing)
```
**Logic for contribution_start_date:**
- Auto-set based on global setting `include_joining_period`
- If `include_joining_period = true`: First day of joining month/quarter/year
- If `include_joining_period = false`: First day of NEXT period after joining
- Can be manually overridden by admin
**NO** `include_joining_period` field on Member - unnecessary due to `contribution_start_date`!
### Global Settings
```
key: "contributions.include_joining_period"
value: Boolean (Default: true)
key: "contributions.default_contribution_type_id"
value: UUID (Required) - Default contribution type for new members
```
**Meaning include_joining_period:**
- `true`: Joining period is included (member pays from joining period)
- `false`: Only from next full period after joining
**Meaning default_contribution_type_id:**
- Every new member automatically gets this contribution type
- Must be configured in admin settings
- Prevents: Members without contribution type
---
## Business Logic
### Period Generation
**Triggers:**
- Member gets contribution type assigned (also during member creation)
- New period begins (Cron job daily/weekly)
- Admin requests manual regeneration
**Algorithm:**
1. Get `member.contribution_start_date` and `member.contribution_type`
2. Calculate first period based on `contribution_start_date`
3. Generate all periods from start to today (or `left_at` if present)
4. Skip existing periods
5. Set `amount` to current `contribution_type.amount`
**Example (Yearly):**
```
Joining date: 15.03.2023
include_joining_period: true
→ contribution_start_date: 01.01.2023
Generated periods:
- 01.01.2023 - 31.12.2023 (joining period)
- 01.01.2024 - 31.12.2024
- 01.01.2025 - 31.12.2025 (current year)
```
**Example (Quarterly):**
```
Joining date: 15.03.2023
include_joining_period: false
→ contribution_start_date: 01.04.2023
Generated periods:
- 01.04.2023 - 30.06.2023 (first full quarter)
- 01.07.2023 - 30.09.2023
- 01.10.2023 - 31.12.2023
- 01.01.2024 - 31.03.2024
- ...
```
### Status Transitions
```
unpaid → paid
unpaid → suspended
paid → unpaid
suspended → paid
suspended → unpaid
```
**Permissions:**
- Admin + Treasurer (Kassenwart) can change status
- Uses existing permission system
### Contribution Type Change
**MVP - Same Interval Only:**
- Member can only choose contribution type with **same interval**
- Example: From "Regular (yearly)" to "Reduced (yearly)" ✓
- Example: From "Regular (yearly)" to "Reduced (monthly)" ✗
**Logic on Change:**
1. Check: New contribution type has same interval
2. If yes: Set `member.contribution_type_id`
3. Future **unpaid** periods: Delete and regenerate with new amount
4. Paid/suspended periods: Remain unchanged (historical amount)
**Future - Different Intervals:**
- Enable interval switching (e.g., yearly → monthly)
- More complex logic for period overlaps
- Needs additional validation
### Member Exit
**Logic:**
- Periods only generated until `member.left_at`
- Existing periods remain visible
- Unpaid exit period can be marked as "suspended"
**Example:**
```
Exit: 15.08.2024
Yearly period: 01.01.2024 - 31.12.2024
→ Period 2024 is shown (Status: unpaid)
→ Admin can set to "suspended"
→ No periods for 2025+ generated
```
---
## UI/UX Design
### Member List View
**New Column: "Contribution Status"**
**Default Display (Last Period):**
- Shows status of **last completed** period
- Example in 2024: Shows contribution for 2023
- Color coding:
- Green: paid ✓
- Red: unpaid ✗
- Gray: suspended ⊘
**Optional: Show Current Period**
- Toggle: "Show current period" (2024)
- Admin decides what to display
**Filters:**
- "Unpaid contributions in last period"
- "Unpaid contributions in current period"
### Member Detail View
**Section: "Contributions"**
**Contribution Type Assignment:**
```
┌─────────────────────────────────────┐
│ Contribution Type: [Dropdown] │
│ ⚠ Only types with same interval │
│ can be selected │
└─────────────────────────────────────┘
```
**Period Table:**
```
┌───────────────┬──────────┬────────┬──────────┬─────────┐
│ Period │ Interval │ Amount │ Status │ Action │
├───────────────┼──────────┼────────┼──────────┼─────────┤
│ 01.01.2023- │ Yearly │ 50 € │ ☑ Paid │ │
│ 31.12.2023 │ │ │ │ │
├───────────────┼──────────┼────────┼──────────┼─────────┤
│ 01.01.2024- │ Yearly │ 60 € │ ☐ Open │ [Mark │
│ 31.12.2024 │ │ │ │ as paid]│
├───────────────┼──────────┼────────┼──────────┼─────────┤
│ 01.01.2025- │ Yearly │ 60 € │ ☐ Open │ [Mark │
│ 31.12.2025 │ │ │ │ as paid]│
└───────────────┴──────────┴────────┴──────────┴─────────┘
Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended
```
**Quick Marking:**
- Checkbox in each row for fast marking
- Button: "Mark selected as paid/unpaid/suspended"
- Bulk action for multiple periods
### Admin: Contribution Types Management
**List:**
```
┌────────────┬──────────┬──────────┬────────────┬─────────┐
│ Name │ Amount │ Interval │ Members │ Actions │
├────────────┼──────────┼──────────┼────────────┼─────────┤
│ Regular │ 60 € │ Yearly │ 45 │ [Edit] │
│ Reduced │ 30 € │ Yearly │ 12 │ [Edit] │
│ Student │ 20 € │ Monthly │ 8 │ [Edit] │
└────────────┴──────────┴──────────┴────────────┴─────────┘
```
**Edit:**
- Name: ✓ editable
- Amount: ✓ editable
- Description: ✓ editable
- Interval: ✗ **NOT** editable (grayed out)
**Warning on Amount Change:**
```
⚠ Change amount to 65 €?
Impact:
- 45 members affected
- Future unpaid periods will be generated with 65 €
- Already paid periods remain with old amount
[Cancel] [Confirm]
```
### Admin: Settings
**Contribution Configuration:**
```
Default Contribution Type: [Dropdown: Contribution Types]
Selected: "Regular (60 €, Yearly)"
This contribution type is automatically assigned to all new members.
Can be changed individually per member.
---
☐ Include joining period
When active:
Members pay from the period of their joining.
Example (Yearly):
Joining: 15.03.2023
→ Pays from 2023
When inactive:
Members pay from the next full period.
Example (Yearly):
Joining: 15.03.2023
→ Pays from 2024
```
---
## Edge Cases
### 1. Contribution Type Change with Different Interval
**MVP:** Blocked (only same interval allowed)
**UI:**
```
Error: Interval change not possible
Current contribution type: "Regular (Yearly)"
Selected contribution type: "Student (Monthly)"
Changing the interval is currently not possible.
Please select a contribution type with interval "Yearly".
[OK]
```
**Future:**
- Allow interval switching
- Calculate overlaps
- Generate new periods without duplicates
### 2. Exit with Unpaid Contributions
**Scenario:**
```
Member exits: 15.08.2024
Yearly period 2024: unpaid
```
**UI Notice on Exit: (Low Prio)**
```
⚠ Unpaid contributions present
This member has 1 unpaid period(s):
- 2024: 60 € (unpaid)
Do you want to continue?
[ ] Mark contribution as "suspended"
[Cancel] [Confirm Exit]
```
### 3. Multiple Unpaid Periods
**Scenario:** Member hasn't paid for 2 years
**Display:**
```
┌───────────────┬──────────┬────────┬──────────┬─────────┐
│ 2023 │ Yearly │ 50 € │ ☐ Open │ [✓] │
│ 2024 │ Yearly │ 60 € │ ☐ Open │ [✓] │
│ 2025 │ Yearly │ 60 € │ ☐ Open │ [ ] │
└───────────────┴──────────┴────────┴──────────┴─────────┘
[Mark selected as paid/unpaid/suspended] (2 selected)
```
### 4. Amount Changes
**Scenario:**
```
2023: Regular = 50 €
2024: Regular = 60 € (increase)
```
**Result:**
- Period 2023: Saved with 50 € (history)
- Period 2024: Generated with 60 € (current)
- Both periods show correct historical amount
### 5. Date Boundaries
**Problem:** What if today = 01.01.2025?
**Solution:**
- Current period (2025) is generated
- Status: unpaid (open)
- Shown in overview
---
## Implementation Scope
### MVP (Phase 1)
**Included:**
- ✓ Contribution types (CRUD)
- ✓ Automatic period generation
- ✓ Status management (paid/unpaid/suspended)
- ✓ Member overview with contribution status
- ✓ Period view per member
- ✓ Quick checkbox marking
- ✓ Bulk actions
- ✓ Amount history
- ✓ Same-interval type change
- ✓ Default contribution type
- ✓ Joining period configuration
**NOT Included:**
- ✗ Interval change (only same interval)
- ✗ Payment details (date, method)
- ✗ Automatic integration (vereinfacht.digital)
- ✗ Prorata calculation
- ✗ Reports/statistics
- ✗ Reminders/dunning (manual via filters)
### Future Enhancements
**Phase 2:**
- Payment details (date, amount, method)
- Interval change for future unpaid periods
- Manual vereinfacht.digital links per member
- Extended filter options
**Phase 3:**
- Automated vereinfacht.digital integration
- Automatic payment matching
- SEPA integration
- Advanced reports

View file

@ -115,7 +115,6 @@ Member (1) → (N) Properties
### Member Constraints
- First name and last name required (min 1 char)
- Email unique, validated format (5-254 chars)
- Birth date cannot be in future
- Join date cannot be in future
- Exit date must be after join date
- Phone: `+?[0-9\- ]{6,20}`
@ -169,7 +168,7 @@ Member (1) → (N) Properties
### Weighted Fields
- **Weight A (highest):** first_name, last_name
- **Weight B:** email, notes
- **Weight C:** birth_date, phone_number, city, street, house_number, postal_code
- **Weight C:** phone_number, city, street, house_number, postal_code
- **Weight D (lowest):** join_date, exit_date
### Usage Example
@ -381,7 +380,7 @@ Install "DBML Language" extension to view/edit DBML files with:
- tokens (jti, purpose, extra_data)
**Personal Data (GDPR):**
- All member fields (name, email, birth_date, address)
- All member fields (name, email, address)
- User email
- Token subject

View file

@ -122,7 +122,6 @@ Table members {
first_name text [not null, note: 'Member first name (min length: 1)']
last_name text [not null, note: 'Member last name (min length: 1)']
email text [not null, unique, note: 'Member email address (5-254 chars, validated)']
birth_date date [null, note: 'Date of birth (cannot be in future)']
paid boolean [null, note: 'Payment status flag']
phone_number text [null, note: 'Contact phone number (format: +?[0-9\- ]{6,20})']
join_date date [null, note: 'Date when member joined club (cannot be in future)']
@ -153,7 +152,7 @@ Table members {
**Club Member Master Data**
Core entity for membership management containing:
- Personal information (name, birth date, email)
- Personal information (name, email)
- Contact details (phone, address)
- Membership status (join/exit dates, payment status)
- Additional notes
@ -183,7 +182,6 @@ Table members {
**Validation Rules:**
- first_name, last_name: min 1 character
- email: 5-254 characters, valid email format
- birth_date: cannot be in future
- join_date: cannot be in future
- exit_date: must be after join_date (if both present)
- phone_number: matches pattern ^\+?[0-9\- ]{6,20}$

View file

@ -1327,6 +1327,33 @@ end
---
## Session: Bulk Email Copy Feature (2025-12-02)
### Feature Summary
Implemented bulk email copy functionality for selected members (#230). Users can select members and copy their email addresses to clipboard.
**Key Features:**
- Copy button appears only when visible members are selected
- Email format: `First Last <email>` with semicolon separator (email client compatible)
- Button shows count of visible selected members (respects search/filter)
- CopyToClipboard JavaScript hook with clipboard API + fallback for older browsers
- Bilingual UI (English/German)
### Key Decisions
1. **Email Format:** "First Last <email>" with semicolon - standard for all major email clients
2. **Visible Member Count:** Button shows only visible selected members, not total selected (better UX when filtering)
3. **Server→Client:** Used `push_event/3` - server formats data, client handles clipboard
### Files Changed
- `lib/mv_web/live/member_live/index.ex` - Event handler, helper function
- `lib/mv_web/live/member_live/index.html.heex` - Copy button
- `assets/js/app.js` - CopyToClipboard hook
- `test/mv_web/member_live/index_test.exs` - 9 new tests
- `priv/gettext/de/LC_MESSAGES/default.po` - German translations
---
## Session: User-Member Linking UI Enhancement (2025-01-13)
### Feature Summary
@ -1559,8 +1586,8 @@ This project demonstrates a modern Phoenix application built with:
---
**Document Version:** 1.2
**Last Updated:** 2025-11-27
**Document Version:** 1.3
**Last Updated:** 2025-12-02
**Maintainer:** Development Team
**Status:** Living Document (update as project evolves)

View file

@ -65,6 +65,7 @@
- ✅ Sorting by basic fields
- ✅ User-Member linking (optional 1:1)
- ✅ Email synchronization between User and Member
- ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230)
**Closed Issues:**
- ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12)
@ -99,10 +100,10 @@
**Closed Issues:**
- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S)
- [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M)
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Remove birthday field from default configuration (S) - Closed 2025-12-02
**Open Issues:**
- [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks]
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority)
- [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority)
**Missing Features:**
@ -186,10 +187,16 @@
**Current State:**
- ✅ Basic "paid" boolean field on members
- ✅ **UI Mock-ups for Contribution Types & Settings** (2025-12-02)
- ⚠️ No payment tracking
**Open Issues:**
- [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority)
- [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Contribution Mockup Pages (Preview)
**Mock-Up Pages (Non-Functional Preview):**
- `/contribution_types` - Contribution Types Management
- `/contribution_settings` - Global Contribution Settings
**Missing Features:**
- ❌ Membership fee configuration

View file

@ -54,6 +54,9 @@ defmodule Mv.Accounts.User do
auth_method :client_secret_jwt
code_verifier true
# Request email and profile scopes from OIDC provider (required for Authentik, Keycloak, etc.)
authorization_params scope: "openid email profile"
# id_token_signed_response_alg "EdDSA" #-> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87
end
@ -69,7 +72,7 @@ defmodule Mv.Accounts.User do
# Default actions for framework/tooling integration:
# - :read -> Standard read used across the app and by admin tooling.
# - :destroy-> Standard delete used by admin tooling and maintenance tasks.
#
#
# NOTE: :create is INTENTIONALLY excluded from defaults!
# Using a default :create would bypass email-synchronization logic.
# Always use one of these explicit create actions instead:
@ -185,7 +188,9 @@ defmodule Mv.Accounts.User do
oidc_user_info = Ash.Changeset.get_argument(changeset, :oidc_user_info)
# Get the new email from OIDC user_info
new_email = Map.get(oidc_user_info, "preferred_username")
# Support both "email" (standard OIDC) and "preferred_username" (Rauthy)
new_email =
Map.get(oidc_user_info, "email") || Map.get(oidc_user_info, "preferred_username")
changeset
|> Ash.Changeset.change_attribute(:oidc_id, oidc_id)
@ -239,8 +244,11 @@ defmodule Mv.Accounts.User do
change fn changeset, _ctx ->
user_info = Ash.Changeset.get_argument(changeset, :user_info)
# Support both "email" (standard OIDC like Authentik, Keycloak) and "preferred_username" (Rauthy)
email = user_info["email"] || user_info["preferred_username"]
changeset
|> Ash.Changeset.change_attribute(:email, user_info["preferred_username"])
|> Ash.Changeset.change_attribute(:email, email)
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
end

View file

@ -24,7 +24,7 @@ defmodule Mv.Membership.Member do
- Email format validation (using EctoCommons.EmailValidator)
- Phone number format: international format with 6-20 digits
- Postal code format: exactly 5 digits (German format)
- Date validations: birth_date and join_date not in future, exit_date after join_date
- Date validations: join_date not in future, exit_date after join_date
- Email uniqueness: prevents conflicts with unlinked users
## Full-Text Search
@ -42,6 +42,10 @@ defmodule Mv.Membership.Member do
@member_search_limit 10
@default_similarity_threshold 0.2
# Use constants from Mv.Constants for member fields
# This ensures consistency across the codebase
@member_fields Mv.Constants.member_fields()
postgres do
table "members"
repo Mv.Repo
@ -58,21 +62,7 @@ defmodule Mv.Membership.Member do
# user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true
accept [
:first_name,
:last_name,
:email,
:birth_date,
:paid,
:phone_number,
:join_date,
:exit_date,
:notes,
:city,
:street,
:house_number,
:postal_code
]
accept @member_fields
change manage_relationship(:custom_field_values, type: :create)
@ -105,21 +95,7 @@ defmodule Mv.Membership.Member do
# user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true
accept [
:first_name,
:last_name,
:email,
:birth_date,
:paid,
:phone_number,
:join_date,
:exit_date,
:notes,
:city,
:street,
:house_number,
:postal_code
]
accept @member_fields
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
@ -308,11 +284,6 @@ defmodule Mv.Membership.Member do
end
end
# Birth date not in the future
validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0),
where: [present(:birth_date)],
message: "cannot be in the future"
# Join date not in the future
validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0),
where: [present(:join_date)],
@ -375,10 +346,6 @@ defmodule Mv.Membership.Member do
constraints min_length: 5, max_length: 254
end
attribute :birth_date, :date do
allow_nil? true
end
attribute :paid, :boolean do
allow_nil? true
end
@ -434,6 +401,70 @@ defmodule Mv.Membership.Member do
identity :unique_email, [:email]
end
@doc """
Checks if a member field should be shown in the overview.
Reads the visibility configuration from Settings resource. If a field is not
configured in settings, it defaults to `true` (visible).
## Parameters
- `field` - Atom representing the member field name (e.g., `:email`, `:street`)
## Returns
- `true` if the field should be shown in overview (default)
- `false` if the field is configured as hidden in settings
## Examples
iex> Member.show_in_overview?(:email)
true
iex> Member.show_in_overview?(:street)
true # or false if configured in settings
"""
@spec show_in_overview?(atom()) :: boolean()
def show_in_overview?(field) when is_atom(field) do
case Mv.Membership.get_settings() do
{:ok, settings} ->
visibility_config = settings.member_field_visibility || %{}
# Normalize map keys to atoms (JSONB may return string keys)
normalized_config = normalize_visibility_config(visibility_config)
# Get value from normalized config, default to true
Map.get(normalized_config, field, true)
{:error, _} ->
# If settings can't be loaded, default to visible
true
end
end
def show_in_overview?(_), do: true
# 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
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError ->
acc
end
_, acc ->
acc
end)
end
defp normalize_visibility_config(_), do: %{}
@doc """
Performs fuzzy search on members using PostgreSQL trigram similarity.

View file

@ -53,6 +53,7 @@ defmodule Mv.Membership do
# It's only used internally as fallback in get_settings/0
# Settings should be created via seed script
define :update_settings, action: :update
define :update_member_field_visibility, action: :update_member_field_visibility
end
end
@ -123,4 +124,37 @@ defmodule Mv.Membership do
|> Ash.Changeset.for_update(:update, attrs)
|> Ash.update(domain: __MODULE__)
end
@doc """
Updates the member field visibility configuration.
This is a specialized action for updating only the member field visibility settings.
It validates that all keys are valid member fields and all values are booleans.
## Parameters
- `settings` - The settings record to update
- `visibility_config` - A map of member field names (strings) to boolean visibility values
(e.g., `%{"street" => false, "house_number" => false}`)
## Returns
- `{:ok, updated_settings}` - Successfully updated settings
- `{:error, error}` - Validation or update error
## Examples
iex> {:ok, settings} = Mv.Membership.get_settings()
iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
iex> updated.member_field_visibility
%{"street" => false, "house_number" => false}
"""
def update_member_field_visibility(settings, visibility_config) do
settings
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
member_field_visibility: visibility_config
})
|> Ash.update(domain: __MODULE__)
end
end

View file

@ -9,6 +9,8 @@ defmodule Mv.Membership.Setting do
## Attributes
- `club_name` - The name of the association/club (required, cannot be empty)
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
(e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
## Singleton Pattern
This resource uses a singleton pattern - there should only be one settings record.
@ -28,6 +30,9 @@ defmodule Mv.Membership.Setting do
# Update club name
{:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"})
# Update member field visibility
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
"""
use Ash.Resource,
domain: Mv.Membership,
@ -49,18 +54,65 @@ defmodule Mv.Membership.Setting do
# Used only as fallback in get_settings/0 if settings don't exist
# Settings should normally be created via seed script
create :create do
accept [:club_name]
accept [:club_name, :member_field_visibility]
end
update :update do
primary? true
accept [:club_name]
require_atomic? false
accept [:club_name, :member_field_visibility]
end
update :update_member_field_visibility do
description "Updates the visibility configuration for member fields in the overview"
require_atomic? false
accept [:member_field_visibility]
end
end
validations do
validate present(:club_name), on: [:create, :update]
validate string_length(:club_name, min: 1), on: [:create, :update]
# Validate member_field_visibility map structure and content
validate fn changeset, _context ->
visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
if visibility && is_map(visibility) do
# Validate all values are booleans
invalid_values =
Enum.filter(visibility, fn {_key, value} ->
not is_boolean(value)
end)
# Validate all keys are valid member fields
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
invalid_keys =
Enum.filter(visibility, fn {key, _value} ->
key not in valid_field_strings
end)
|> Enum.map(fn {key, _value} -> key end)
cond do
not Enum.empty?(invalid_values) ->
{:error,
field: :member_field_visibility,
message: "All values in member_field_visibility must be booleans"}
not Enum.empty?(invalid_keys) ->
{:error,
field: :member_field_visibility,
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
true ->
:ok
end
else
:ok
end
end,
on: [:create, :update]
end
attributes do
@ -75,6 +127,12 @@ defmodule Mv.Membership.Setting do
min_length: 1
]
attribute :member_field_visibility, :map,
allow_nil?: true,
public?: true,
description:
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
timestamps()
end
end

34
lib/mv/constants.ex Normal file
View file

@ -0,0 +1,34 @@
defmodule Mv.Constants do
@moduledoc """
Module for defining constants and atoms.
"""
@member_fields [
:first_name,
:last_name,
:email,
:paid,
:phone_number,
:join_date,
:exit_date,
:notes,
:city,
:street,
:house_number,
:postal_code
]
@custom_field_prefix "custom_field_"
def member_fields, do: @member_fields
@doc """
Returns the prefix used for custom field keys in field visibility maps.
## Examples
iex> Mv.Constants.custom_field_prefix()
"custom_field_"
"""
def custom_field_prefix, do: @custom_field_prefix
end

View file

@ -42,7 +42,11 @@ defmodule MvWeb.CoreComponents do
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :kind, :atom,
values: [:info, :error, :success, :warning],
doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
@ -56,22 +60,26 @@ defmodule MvWeb.CoreComponents do
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="toast toast-top toast-end z-50"
class="z-50 toast toast-top toast-end"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
@kind == :error && "alert-error",
@kind == :success && "bg-green-500 text-white",
@kind == :warning && "bg-blue-100 text-blue-800 border border-blue-300"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :success} name="hero-check-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :warning} name="hero-information-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
<button type="button" class="self-start cursor-pointer group" aria-label={gettext("close")}>
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
@ -111,6 +119,123 @@ defmodule MvWeb.CoreComponents do
end
end
@doc """
Renders a dropdown menu.
## Examples
<.dropdown_menu items={@items} open={@open} phx_target={@myself} />
"""
attr :id, :string, default: "dropdown-menu"
attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps"
attr :button_label, :string, default: "Dropdown"
attr :icon, :string, default: nil
attr :checkboxes, :boolean, default: false
attr :selected, :map, default: %{}
attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
def dropdown_menu(assigns) do
~H"""
<div
class="relative"
phx-click-away="close_dropdown"
phx-target={@phx_target}
phx-window-keydown="close_dropdown"
phx-key="Escape"
data-testid="dropdown-menu"
>
<button
type="button"
tabindex="0"
role="button"
aria-haspopup="menu"
aria-expanded={@open}
aria-controls={@id}
class="btn btn-ghost"
phx-click="toggle_dropdown"
phx-target={@phx_target}
data-testid="dropdown-button"
>
<%= if @icon do %>
<.icon name={@icon} />
<% end %>
<span>{@button_label}</span>
</button>
<ul
:if={@open}
id={@id}
role="menu"
class="absolute right-0 mt-2 bg-base-100 z-[100] p-2 shadow-lg rounded-box w-64 max-h-96 overflow-y-auto border border-base-300"
tabindex="0"
phx-target={@phx_target}
>
<li :if={@show_select_buttons} role="none">
<div class="flex justify-between items-center mb-2 px-2">
<span class="font-semibold">{gettext("Options")}</span>
<div class="flex gap-1">
<button
type="button"
role="menuitem"
aria-label={gettext("Select all")}
phx-click="select_all"
phx-target={@phx_target}
class="btn btn-xs btn-ghost"
>
{gettext("All")}
</button>
<button
type="button"
role="menuitem"
aria-label={gettext("Select none")}
phx-click="select_none"
phx-target={@phx_target}
class="btn btn-xs btn-ghost"
>
{gettext("None")}
</button>
</div>
</div>
</li>
<li :if={@show_select_buttons} role="separator" class="divider my-1"></li>
<%= for item <- @items do %>
<li role="none">
<button
type="button"
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
aria-checked={
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
}
tabindex="0"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left"
phx-click="select_item"
phx-keydown="select_item"
phx-key="Enter"
phx-value-item={item.value}
phx-target={@phx_target}
>
<%= if @checkboxes do %>
<input
type="checkbox"
checked={Map.get(@selected, item.value, true)}
class="checkbox checkbox-sm checkbox-primary"
tabindex="-1"
aria-hidden="true"
/>
<% end %>
<span>{item.label}</span>
</button>
</li>
<% end %>
</ul>
</div>
"""
end
@doc """
Renders an input with label and error messages.
@ -180,7 +305,7 @@ defmodule MvWeb.CoreComponents do
end)
~H"""
<fieldset class="fieldset mb-2">
<fieldset class="mb-2 fieldset">
<label>
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
<span class="label">
@ -192,7 +317,11 @@ defmodule MvWeb.CoreComponents do
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
/>{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
@ -202,9 +331,15 @@ defmodule MvWeb.CoreComponents do
def input(%{type: "select"} = assigns) do
~H"""
<fieldset class="fieldset mb-2">
<fieldset class="mb-2 fieldset">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
<select
id={@id}
name={@name}
@ -223,9 +358,15 @@ defmodule MvWeb.CoreComponents do
def input(%{type: "textarea"} = assigns) do
~H"""
<fieldset class="fieldset mb-2">
<fieldset class="mb-2 fieldset">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
<textarea
id={@id}
name={@name}
@ -244,9 +385,15 @@ defmodule MvWeb.CoreComponents do
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<fieldset class="fieldset mb-2">
<fieldset class="mb-2 fieldset">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
<input
type={@type}
name={@name}
@ -338,61 +485,63 @@ defmodule MvWeb.CoreComponents do
end
~H"""
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :for={dyn_col <- @dynamic_cols}>
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
field={"custom_field_#{dyn_col[:custom_field].id}"}
label={dyn_col[:custom_field].name}
sort_field={@sort_field}
sort_order={@sort_order}
/>
</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td
:for={dyn_col <- @dynamic_cols}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{if dyn_col[:render] do
rendered = dyn_col[:render].(@row_item.(row))
<div class="overflow-auto">
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :for={dyn_col <- @dynamic_cols}>
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
field={"custom_field_#{dyn_col[:custom_field].id}"}
label={dyn_col[:custom_field].name}
sort_field={@sort_field}
sort_order={@sort_order}
/>
</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={["max-w-xs truncate", @row_click && "hover:cursor-pointer"]}
>
{render_slot(col, @row_item.(row))}
</td>
<td
:for={dyn_col <- @dynamic_cols}
phx-click={@row_click && @row_click.(row)}
class={["max-w-xs truncate", @row_click && "hover:cursor-pointer"]}
>
{if dyn_col[:render] do
rendered = dyn_col[:render].(@row_item.(row))
if rendered == "" do
""
if rendered == "" do
""
else
rendered
end
else
rendered
end
else
""
end}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
""
end}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
</div>
"""
end
@ -517,7 +666,7 @@ defmodule MvWeb.CoreComponents do
<div class="mt-14">
<dl class="-my-4 divide-y divide-zinc-100">
<div :for={{name, value} <- @items} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
<dt class="w-1/4 flex-none text-zinc-500">{name}</dt>
<dt class="flex-none w-1/4 text-zinc-500">{name}</dt>
<dd class="text-zinc-700">{value}</dd>
</div>
</dl>

View file

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

View file

@ -23,8 +23,19 @@ defmodule MvWeb.Layouts.Navbar do
<a class="btn btn-ghost text-xl">{@club_name}</a>
<ul class="menu menu-horizontal bg-base-200">
<li><.link navigate="/members">{gettext("Members")}</.link></li>
<li><.link navigate="/custom_fields">{gettext("Custom Fields")}</.link></li>
<li><.link navigate="/settings">{gettext("Settings")}</.link></li>
<li><.link navigate="/users">{gettext("Users")}</.link></li>
<li>
<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="/contribution_settings">{gettext("Contribution Settings")}</.link>
</li>
</ul>
</details>
</li>
</ul>
</div>
<div class="flex gap-2">

View file

@ -39,6 +39,11 @@ defmodule MvWeb.Endpoint do
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :mv
end
# Enable Ecto SQL Sandbox in test environment for async tests
if Application.compile_env(:mv, :sql_sandbox) do
plug Phoenix.Ecto.SQL.Sandbox
end
plug Phoenix.LiveDashboard.RequestLogger,
param_key: "request_logger",
cookie_key: "request_logger"

View file

@ -0,0 +1,27 @@
defmodule MvWeb.Helpers.DateFormatter do
@moduledoc """
Centralized date formatting helper for the application.
Formats dates in European format (dd.mm.yyyy).
"""
use Gettext, backend: MvWeb.Gettext
@doc """
Formats a Date struct to European format (dd.mm.yyyy).
## Examples
iex> MvWeb.Helpers.DateFormatter.format_date(~D[2024-03-15])
"15.03.2024"
iex> MvWeb.Helpers.DateFormatter.format_date(nil)
""
"""
def format_date(%Date{} = date) do
Calendar.strftime(date, "%d.%m.%Y")
end
def format_date(nil), do: ""
def format_date(_), do: "Invalid date"
end

View file

@ -0,0 +1,176 @@
defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
@moduledoc """
LiveComponent for managing field visibility in the member overview.
Provides an accessible dropdown menu where users can select/deselect
which member fields and custom fields are visible in the table.
## Props
- `:all_fields` - List of all available fields
- `:custom_fields` - List of CustomField resources
- `:selected_fields` - Map field_name boolean
- `:id` - Component ID
## Events sent to parent:
- `{:field_toggled, field, value}`
- `{:fields_selected, map}`
"""
use MvWeb, :live_component
# ---------------------------------------------------------------------------
# UPDATE
# ---------------------------------------------------------------------------
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:open, fn -> false end)
|> assign_new(:all_fields, fn -> [] end)
|> assign_new(:custom_fields, fn -> [] end)
|> assign_new(:selected_fields, fn -> %{} end)
{:ok, socket}
end
# ---------------------------------------------------------------------------
# RENDER
# ---------------------------------------------------------------------------
@impl true
def render(assigns) do
all_fields = assigns.all_fields || []
custom_fields = assigns.custom_fields || []
all_items =
Enum.map(extract_member_field_keys(all_fields), fn field ->
%{
value: field_to_string(field),
label: format_field_label(field)
}
end) ++
Enum.map(extract_custom_field_keys(all_fields), fn field ->
%{
value: field,
label: format_custom_field_label(field, custom_fields)
}
end)
assigns = assign(assigns, :all_items, all_items)
# LiveComponents require a static HTML element as root, not a function component
~H"""
<div>
<.dropdown_menu
id="field-visibility-menu"
icon="hero-adjustments-horizontal"
button_label={gettext("Columns")}
items={@all_items}
checkboxes={true}
selected={@selected_fields}
open={@open}
show_select_buttons={true}
phx_target={@myself}
/>
</div>
"""
end
# ---------------------------------------------------------------------------
# EVENTS (matching the Core Component API)
# ---------------------------------------------------------------------------
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
# toggle single item
def handle_event("select_item", %{"item" => item}, socket) do
current = Map.get(socket.assigns.selected_fields, item, true)
updated = Map.put(socket.assigns.selected_fields, item, !current)
send(self(), {:field_toggled, item, !current})
{:noreply, assign(socket, :selected_fields, updated)}
end
# select all
def handle_event("select_all", _params, socket) do
all =
socket.assigns.all_fields
|> Enum.map(&field_to_string/1)
|> Enum.map(&{&1, true})
|> Enum.into(%{})
send(self(), {:fields_selected, all})
{:noreply, assign(socket, :selected_fields, all)}
end
# select none
def handle_event("select_none", _params, socket) do
none =
socket.assigns.all_fields
|> Enum.map(&field_to_string/1)
|> Enum.map(&{&1, false})
|> Enum.into(%{})
send(self(), {:fields_selected, none})
{:noreply, assign(socket, :selected_fields, none)}
end
# ---------------------------------------------------------------------------
# HELPERS (with defensive nil guards)
# ---------------------------------------------------------------------------
defp extract_member_field_keys(nil), do: []
defp extract_member_field_keys(fields) do
prefix = Mv.Constants.custom_field_prefix()
Enum.filter(fields, fn field ->
is_atom(field) ||
(is_binary(field) && not String.starts_with?(field, prefix))
end)
end
defp extract_custom_field_keys(nil), do: []
defp extract_custom_field_keys(fields) do
prefix = Mv.Constants.custom_field_prefix()
Enum.filter(fields, fn field ->
is_binary(field) && String.starts_with?(field, prefix)
end)
end
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
defp format_field_label(field) do
field
|> field_to_string()
|> String.replace("_", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end
defp format_custom_field_label(field_string, custom_fields) do
id = String.trim_leading(field_string, Mv.Constants.custom_field_prefix())
find_custom_field_name(id, field_string, custom_fields)
end
defp find_custom_field_name("", field_string, _custom_fields), do: field_string
defp find_custom_field_name(id, _field_string, custom_fields) do
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do
nil -> gettext("Custom Field %{id}", id: id)
custom_field -> custom_field.name
end
end
end

View file

@ -0,0 +1,146 @@
defmodule MvWeb.Components.PaymentFilterComponent do
@moduledoc """
Provides the PaymentFilter Live-Component.
A dropdown filter for filtering members by payment status (paid/not paid/all).
Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
## Props
- `:paid_filter` - Current filter state: `nil` (all), `:paid`, or `:not_paid`
- `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
## Events
- Sends `{:payment_filter_changed, filter}` to parent when filter changes
"""
use MvWeb, :live_component
@impl true
def mount(socket) do
{:ok, assign(socket, :open, false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(:id, assigns.id)
|> assign(:paid_filter, assigns[:paid_filter])
|> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div
class="relative"
id={@id}
phx-window-keydown={@open && "close_dropdown"}
phx-key="Escape"
phx-target={@myself}
>
<button
type="button"
class={[
"btn btn-ghost gap-2",
@paid_filter && "btn-active"
]}
phx-click="toggle_dropdown"
phx-target={@myself}
aria-haspopup="true"
aria-expanded={to_string(@open)}
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>
</button>
<ul
:if={@open}
class="menu dropdown-content bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg absolute right-0 mt-2"
role="menu"
aria-label={gettext("Payment filter")}
phx-click-away="close_dropdown"
phx-target={@myself}
>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == nil)}
class={@paid_filter == nil && "active"}
phx-click="select_filter"
phx-value-filter=""
phx-target={@myself}
>
<.icon name="hero-users" class="h-4 w-4" />
{gettext("All")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == :paid)}
class={@paid_filter == :paid && "active"}
phx-click="select_filter"
phx-value-filter="paid"
phx-target={@myself}
>
<.icon name="hero-check-circle" class="h-4 w-4 text-success" />
{gettext("Paid")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == :not_paid)}
class={@paid_filter == :not_paid && "active"}
phx-click="select_filter"
phx-value-filter="not_paid"
phx-target={@myself}
>
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
{gettext("Not paid")}
</button>
</li>
</ul>
</div>
"""
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
@impl true
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
@impl true
def handle_event("select_filter", %{"filter" => filter_str}, socket) do
filter = parse_filter(filter_str)
# Close dropdown and notify parent
socket = assign(socket, :open, false)
send(self(), {:payment_filter_changed, filter})
{:noreply, socket}
end
# Parse filter string to atom
defp parse_filter("paid"), do: :paid
defp parse_filter("not_paid"), do: :not_paid
defp parse_filter(_), do: nil
# Get display label for current filter
defp filter_label(nil), do: gettext("All")
defp filter_label(:paid), do: gettext("Paid")
defp filter_label(:not_paid), do: gettext("Not paid")
end

View file

@ -19,7 +19,7 @@ defmodule MvWeb.Components.SortHeaderComponent do
@impl true
def render(assigns) do
~H"""
<div class="tooltip" data-tip={aria_sort(@field, @sort_field, @sort_order)}>
<div class="tooltip tooltip-bottom" data-tip={aria_sort(@field, @sort_field, @sort_order)}>
<button
type="button"
aria-label={aria_sort(@field, @sort_field, @sort_order)}

View file

@ -0,0 +1,345 @@
defmodule MvWeb.ContributionPeriodLive.Show do
@moduledoc """
Mock-up LiveView for Member Contribution Periods (Admin/Treasurer View).
This is a preview-only page that displays the planned UI for viewing
and managing contribution periods for a specific member.
It shows static mock data and is not functional.
## Planned Features (Future Implementation)
- Display all contribution periods for a member
- Show period dates, interval, amount, and status
- Quick status change (paid/unpaid/suspended)
- Bulk marking of multiple periods
- Notes per period
## Note
This page is intentionally non-functional and serves as a UI mockup
for the upcoming Membership Contributions feature.
"""
use MvWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, gettext("Member Contributions"))
|> assign(:member, mock_member())
|> assign(:periods, mock_periods())
|> assign(:selected_periods, MapSet.new())}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.mockup_warning />
<.header>
{gettext("Contributions for %{name}", name: "#{@member.first_name} #{@member.last_name}")}
<:subtitle>
{gettext("Contribution type")}:
<span class="font-semibold">{@member.contribution_type}</span>
· {gettext("Member since")}: <span class="font-mono">{@member.joined_at}</span>
</:subtitle>
<:actions>
<.link navigate={~p"/contribution_settings"} class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back to Settings")}
</.link>
</:actions>
</.header>
<%!-- Member Info Card --%>
<div class="mb-6 shadow card bg-base-100">
<div class="card-body">
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<div>
<span class="text-sm text-base-content/60">{gettext("Email")}</span>
<p class="font-medium">{@member.email}</p>
</div>
<div>
<span class="text-sm text-base-content/60">{gettext("Contribution Start")}</span>
<p class="font-mono">{@member.contribution_start}</p>
</div>
<div>
<span class="text-sm text-base-content/60">{gettext("Total Contributions")}</span>
<p class="font-semibold">{length(@periods)}</p>
</div>
<div>
<span class="text-sm text-base-content/60">{gettext("Open Contributions")}</span>
<p class="font-semibold text-error">
{Enum.count(@periods, &(&1.status == :unpaid))}
</p>
</div>
</div>
</div>
</div>
<%!-- Contribution Type Change --%>
<div class="mb-6 card bg-base-200">
<div class="py-4 card-body">
<div class="flex flex-wrap items-center gap-4">
<span class="font-semibold">{gettext("Change Contribution Type")}:</span>
<select class="w-64 select select-bordered select-sm" disabled>
<option selected>{@member.contribution_type} (60,00 , {gettext("Yearly")})</option>
<option>{gettext("Reduced")} (30,00 , {gettext("Yearly")})</option>
<option>{gettext("Honorary")} (0,00 , {gettext("Yearly")})</option>
</select>
<span
class="text-sm text-base-content/60 cursor-help tooltip tooltip-bottom"
data-tip={
gettext(
"Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
)
}
>
<.icon name="hero-question-mark-circle" class="inline size-4" />
{gettext("Why are not all contribution types shown?")}
</span>
</div>
</div>
</div>
<%!-- Bulk Actions --%>
<div class="flex flex-wrap items-center gap-4 mb-4">
<span class="text-sm text-base-content/60">
{ngettext(
"%{count} period selected",
"%{count} periods selected",
MapSet.size(@selected_periods),
count: MapSet.size(@selected_periods)
)}
</span>
<button class="btn btn-sm btn-success" disabled>
<.icon name="hero-check" class="size-4" />
{gettext("Mark as Paid")}
</button>
<button class="btn btn-sm btn-ghost" disabled>
<.icon name="hero-minus-circle" class="size-4" />
{gettext("Mark as Suspended")}
</button>
<button class="btn btn-sm btn-ghost" disabled>
<.icon name="hero-x-circle" class="size-4" />
{gettext("Mark as Unpaid")}
</button>
</div>
<%!-- Periods Table --%>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>
<input type="checkbox" class="checkbox checkbox-sm" disabled />
</th>
<th>{gettext("Time Period")}</th>
<th>{gettext("Interval")}</th>
<th>{gettext("Amount")}</th>
<th>{gettext("Status")}</th>
<th>{gettext("Notes")}</th>
<th>{gettext("Actions")}</th>
</tr>
</thead>
<tbody>
<tr :for={period <- @periods} class={period_row_class(period.status)}>
<td>
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={MapSet.member?(@selected_periods, period.id)}
disabled
/>
</td>
<td>
<div class="font-mono">
{period.period_start} {period.period_end}
</div>
<div :if={period.is_current} class="mt-1 badge badge-info badge-sm">
{gettext("Current")}
</div>
</td>
<td>
<span class="badge badge-outline badge-sm">{format_interval(period.interval)}</span>
</td>
<td>
<span class="font-mono">{format_currency(period.amount)}</span>
</td>
<td>
<.status_badge status={period.status} />
</td>
<td>
<span :if={period.notes} class="text-sm italic text-base-content/60">
{period.notes}
</span>
<span :if={!period.notes} class="text-base-content/30"></span>
</td>
<td class="w-0 font-semibold whitespace-nowrap">
<div class="flex gap-4">
<.link
href="#"
class={[
"cursor-not-allowed",
if(period.status == :paid, do: "invisible", else: "opacity-50")
]}
>
{gettext("Paid")}
</.link>
<.link
href="#"
class={[
"cursor-not-allowed",
if(period.status == :suspended, do: "invisible", else: "opacity-50")
]}
>
{gettext("Suspend")}
</.link>
<.link
href="#"
class={[
"cursor-not-allowed",
if(period.status != :paid, do: "invisible", else: "opacity-50")
]}
>
{gettext("Reopen")}
</.link>
<.link href="#" class="opacity-50 cursor-not-allowed">
{gettext("Note")}
</.link>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</Layouts.app>
"""
end
# Mock-up warning banner component - subtle orange style
defp mockup_warning(assigns) do
~H"""
<div class="flex items-center gap-3 px-4 py-3 mb-6 border rounded-lg border-warning text-warning bg-base-100">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
<div>
<span class="font-semibold">{gettext("Preview Mockup")}</span>
<span class="ml-2 text-sm text-base-content/70">
{gettext("This page is not functional and only displays the planned features.")}
</span>
</div>
</div>
"""
end
# Status badge component
attr :status, :atom, required: true
defp status_badge(%{status: :paid} = assigns) do
~H"""
<span class="gap-1 badge badge-success">
<.icon name="hero-check-circle-mini" class="size-3" />
{gettext("Paid")}
</span>
"""
end
defp status_badge(%{status: :unpaid} = assigns) do
~H"""
<span class="gap-1 badge badge-error">
<.icon name="hero-x-circle-mini" class="size-3" />
{gettext("Unpaid")}
</span>
"""
end
defp status_badge(%{status: :suspended} = assigns) do
~H"""
<span class="gap-1 badge badge-neutral">
<.icon name="hero-pause-circle-mini" class="size-3" />
{gettext("Suspended")}
</span>
"""
end
defp period_row_class(:unpaid), do: "bg-error/5"
defp period_row_class(:suspended), do: "bg-base-200/50"
defp period_row_class(_), do: ""
# Mock member data
defp mock_member do
%{
id: "123",
first_name: "Maria",
last_name: "Weber",
email: "maria.weber@example.de",
contribution_type: gettext("Regular"),
joined_at: "15.03.2021",
contribution_start: "01.01.2021"
}
end
# Mock periods data
defp mock_periods do
[
%{
id: "p1",
period_start: "01.01.2025",
period_end: "31.12.2025",
interval: :yearly,
amount: Decimal.new("60.00"),
status: :unpaid,
notes: nil,
is_current: true
},
%{
id: "p2",
period_start: "01.01.2024",
period_end: "31.12.2024",
interval: :yearly,
amount: Decimal.new("60.00"),
status: :paid,
notes: gettext("Paid via bank transfer"),
is_current: false
},
%{
id: "p3",
period_start: "01.01.2023",
period_end: "31.12.2023",
interval: :yearly,
amount: Decimal.new("50.00"),
status: :paid,
notes: nil,
is_current: false
},
%{
id: "p4",
period_start: "01.01.2022",
period_end: "31.12.2022",
interval: :yearly,
amount: Decimal.new("50.00"),
status: :paid,
notes: nil,
is_current: false
},
%{
id: "p5",
period_start: "01.01.2021",
period_end: "31.12.2021",
interval: :yearly,
amount: Decimal.new("50.00"),
status: :suspended,
notes: gettext("Joining year - reduced to 0"),
is_current: false
}
]
end
defp format_currency(%Decimal{} = amount) do
"#{Decimal.to_string(amount)}"
end
defp format_interval(:monthly), do: gettext("Monthly")
defp format_interval(:quarterly), do: gettext("Quarterly")
defp format_interval(:half_yearly), do: gettext("Half-yearly")
defp format_interval(:yearly), do: gettext("Yearly")
end

View file

@ -0,0 +1,277 @@
defmodule MvWeb.ContributionSettingsLive do
@moduledoc """
Mock-up LiveView for Contribution Settings (Admin).
This is a preview-only page that displays the planned UI for managing
global contribution settings. It shows static mock data and is not functional.
## Planned Features (Future Implementation)
- Set default contribution type for new members
- Configure whether joining period is included in contributions
- Explanatory text with examples
## Settings
- `default_contribution_type_id` - UUID of the default contribution type
- `include_joining_period` - Boolean whether to include joining period
## Note
This page is intentionally non-functional and serves as a UI mockup
for the upcoming Membership Contributions feature.
"""
use MvWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, gettext("Contribution Settings"))
|> assign(:contribution_types, mock_contribution_types())
|> assign(:selected_type_id, "1")
|> assign(:include_joining_period, true)}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.mockup_warning />
<.header>
{gettext("Contribution Settings")}
<:subtitle>
{gettext("Configure global settings for membership contributions.")}
</:subtitle>
</.header>
<div class="grid gap-6 lg:grid-cols-2">
<%!-- Settings Form --%>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<.icon name="hero-cog-6-tooth" class="size-5" />
{gettext("Global Settings")}
</h2>
<form class="space-y-6">
<%!-- Default Contribution Type --%>
<fieldset class="fieldset">
<label class="label">
<span class="label-text font-semibold">
{gettext("Default Contribution Type")}
</span>
</label>
<select class="select select-bordered w-full" disabled>
<option :for={ct <- @contribution_types} selected={ct.id == @selected_type_id}>
{ct.name} ({format_currency(ct.amount)}, {format_interval(ct.interval)})
</option>
</select>
<p class="text-sm text-base-content/60 mt-2">
{gettext(
"This contribution type is automatically assigned to all new members. Can be changed individually per member."
)}
</p>
</fieldset>
<%!-- Include Joining Period --%>
<fieldset class="fieldset">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
class="checkbox checkbox-primary"
checked={@include_joining_period}
disabled
/>
<span class="label-text font-semibold">
{gettext("Include joining period")}
</span>
</label>
<div class="ml-9 space-y-2">
<p class="text-sm text-base-content/60">
{gettext("When active: Members pay from the period of their joining.")}
</p>
<p class="text-sm text-base-content/60">
{gettext("When inactive: Members pay from the next full period after joining.")}
</p>
</div>
</fieldset>
<div class="divider"></div>
<button type="button" class="btn btn-primary w-full" disabled>
<.icon name="hero-check" class="size-5" />
{gettext("Save Settings")}
</button>
</form>
</div>
</div>
<%!-- Examples Card --%>
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title">
<.icon name="hero-light-bulb" class="size-5" />
{gettext("Examples")}
</h2>
<.example_section
title={gettext("Yearly Interval - Joining Period Included")}
joining_date="15.03.2023"
include_joining={true}
start_date="01.01.2023"
periods={["2023", "2024", "2025"]}
note={gettext("Member pays for the year they joined")}
/>
<div class="divider"></div>
<.example_section
title={gettext("Yearly Interval - Joining Period Excluded")}
joining_date="15.03.2023"
include_joining={false}
start_date="01.01.2024"
periods={["2024", "2025"]}
note={gettext("Member pays from the next full year")}
/>
<div class="divider"></div>
<.example_section
title={gettext("Quarterly Interval - Joining Period Excluded")}
joining_date="15.05.2024"
include_joining={false}
start_date="01.07.2024"
periods={["Q3/2024", "Q4/2024", "Q1/2025"]}
note={gettext("Member pays from the next full quarter")}
/>
<div class="divider"></div>
<.example_section
title={gettext("Monthly Interval - Joining Period Included")}
joining_date="15.03.2024"
include_joining={true}
start_date="01.03.2024"
periods={["03/2024", "04/2024", "05/2024", "..."]}
note={gettext("Member pays from the joining month")}
/>
</div>
</div>
</div>
<.example_member_card />
</Layouts.app>
"""
end
# Example member card with link to period view
defp example_member_card(assigns) do
~H"""
<div class="card bg-base-100 shadow-xl mt-6">
<div class="card-body">
<h2 class="card-title">
<.icon name="hero-user" class="size-5" />
{gettext("Example: Member Contribution View")}
</h2>
<p class="text-base-content/70">
{gettext(
"See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
)}
</p>
<div class="card-actions justify-end">
<.link navigate={~p"/contributions/member/example"} class="btn btn-primary btn-sm">
<.icon name="hero-eye" class="size-4" />
{gettext("View Example Member")}
</.link>
</div>
</div>
</div>
"""
end
# Mock-up warning banner component - subtle orange style
defp mockup_warning(assigns) do
~H"""
<div class="border border-warning text-warning bg-base-100 rounded-lg px-4 py-3 mb-6 flex items-center gap-3">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
<div>
<span class="font-semibold">{gettext("Preview Mockup")}</span>
<span class="text-sm text-base-content/70 ml-2">
{gettext("This page is not functional and only displays the planned features.")}
</span>
</div>
</div>
"""
end
# Example section component
attr :title, :string, required: true
attr :joining_date, :string, required: true
attr :include_joining, :boolean, required: true
attr :start_date, :string, required: true
attr :periods, :list, required: true
attr :note, :string, required: true
defp example_section(assigns) do
~H"""
<div class="space-y-2">
<h3 class="font-semibold text-sm">{@title}</h3>
<div class="bg-base-300 rounded-lg p-3 text-sm space-y-1">
<p>
<span class="text-base-content/60">{gettext("Joining date")}:</span>
<span class="font-mono">{@joining_date}</span>
</p>
<p>
<span class="text-base-content/60">{gettext("Contribution start")}:</span>
<span class="font-mono font-semibold text-primary">{@start_date}</span>
</p>
<p>
<span class="text-base-content/60">{gettext("Generated periods")}:</span>
<span class="font-mono">
{Enum.join(@periods, ", ")}
</span>
</p>
</div>
<p class="text-xs text-base-content/60 italic"> {@note}</p>
</div>
"""
end
# Mock data for demonstration
defp mock_contribution_types do
[
%{
id: "1",
name: gettext("Regular"),
amount: Decimal.new("60.00"),
interval: :yearly
},
%{
id: "2",
name: gettext("Reduced"),
amount: Decimal.new("30.00"),
interval: :yearly
},
%{
id: "3",
name: gettext("Student"),
amount: Decimal.new("5.00"),
interval: :monthly
},
%{
id: "4",
name: gettext("Family"),
amount: Decimal.new("25.00"),
interval: :quarterly
}
]
end
defp format_currency(%Decimal{} = amount) do
"#{Decimal.to_string(amount)}"
end
defp format_interval(:monthly), do: gettext("Monthly")
defp format_interval(:quarterly), do: gettext("Quarterly")
defp format_interval(:half_yearly), do: gettext("Half-yearly")
defp format_interval(:yearly), do: gettext("Yearly")
end

View file

@ -0,0 +1,205 @@
defmodule MvWeb.ContributionTypeLive.Index do
@moduledoc """
Mock-up LiveView for Contribution Types Management (Admin).
This is a preview-only page that displays the planned UI for managing
contribution types. It shows static mock data and is not functional.
## Planned Features (Future Implementation)
- List all contribution types
- Display: Name, Amount, Interval, Member count
- Create new contribution types
- Edit existing contribution types (name, amount, description - NOT interval)
- Delete contribution types (if no members assigned)
## Note
This page is intentionally non-functional and serves as a UI mockup
for the upcoming Membership Contributions feature.
"""
use MvWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, gettext("Contribution Types"))
|> assign(:contribution_types, mock_contribution_types())}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.mockup_warning />
<.header>
{gettext("Contribution Types")}
<:subtitle>
{gettext("Manage contribution types for membership fees.")}
</:subtitle>
<:actions>
<button class="btn btn-primary" disabled>
<.icon name="hero-plus" /> {gettext("New Contribution Type")}
</button>
</:actions>
</.header>
<.table id="contribution_types" rows={@contribution_types} row_id={fn ct -> "ct-#{ct.id}" end}>
<:col :let={ct} label={gettext("Name")}>
<span class="font-medium">{ct.name}</span>
<p :if={ct.description} class="text-sm text-base-content/60">{ct.description}</p>
</:col>
<:col :let={ct} label={gettext("Amount")}>
<span class="font-mono">{format_currency(ct.amount)}</span>
</:col>
<:col :let={ct} label={gettext("Interval")}>
<span class="badge badge-outline">{format_interval(ct.interval)}</span>
</:col>
<:col :let={ct} label={gettext("Members")}>
<span class="badge badge-ghost">{ct.member_count}</span>
</:col>
<:action :let={_ct}>
<button class="btn btn-ghost btn-xs" disabled title={gettext("Edit")}>
<.icon name="hero-pencil" class="size-4" />
</button>
</:action>
<:action :let={ct}>
<button
class="btn btn-ghost btn-xs text-error"
disabled
title={
if ct.member_count > 0,
do: gettext("Cannot delete - members assigned"),
else: gettext("Delete")
}
>
<.icon name="hero-trash" class="size-4" />
</button>
</:action>
</.table>
<.info_card />
</Layouts.app>
"""
end
# Mock-up warning banner component - subtle orange style
defp mockup_warning(assigns) do
~H"""
<div class="border border-warning text-warning bg-base-100 rounded-lg px-4 py-3 mb-6 flex items-center gap-3">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
<div>
<span class="font-semibold">{gettext("Preview Mockup")}</span>
<span class="text-sm text-base-content/70 ml-2">
{gettext("This page is not functional and only displays the planned features.")}
</span>
</div>
</div>
"""
end
# Info card explaining the contribution 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 Contribution Types")}
</h2>
<div class="prose prose-sm max-w-none">
<p>
{gettext(
"Contribution 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
# Mock data for demonstration
defp mock_contribution_types do
[
%{
id: "1",
name: gettext("Regular"),
description: gettext("Standard membership fee for regular members"),
amount: Decimal.new("60.00"),
interval: :yearly,
member_count: 45
},
%{
id: "2",
name: gettext("Reduced"),
description: gettext("Reduced fee for unemployed, pensioners, or low income"),
amount: Decimal.new("30.00"),
interval: :yearly,
member_count: 12
},
%{
id: "3",
name: gettext("Student"),
description: gettext("Monthly fee for students and trainees"),
amount: Decimal.new("5.00"),
interval: :monthly,
member_count: 8
},
%{
id: "4",
name: gettext("Family"),
description: gettext("Quarterly fee for family memberships"),
amount: Decimal.new("25.00"),
interval: :quarterly,
member_count: 15
},
%{
id: "5",
name: gettext("Supporting Member"),
description: gettext("Half-yearly contribution for supporting members"),
amount: Decimal.new("100.00"),
interval: :half_yearly,
member_count: 3
},
%{
id: "6",
name: gettext("Honorary"),
description: gettext("No fee for honorary members"),
amount: Decimal.new("0.00"),
interval: :yearly,
member_count: 2
}
]
end
defp format_currency(%Decimal{} = amount) do
"#{Decimal.to_string(amount)}"
end
defp format_interval(:monthly), do: gettext("Monthly")
defp format_interval(:quarterly), do: gettext("Quarterly")
defp format_interval(:half_yearly), do: gettext("Half-yearly")
defp format_interval(:yearly), do: gettext("Yearly")
end

View file

@ -1,142 +0,0 @@
defmodule MvWeb.CustomFieldLive.Form do
@moduledoc """
LiveView form for creating and editing custom fields (admin).
## Features
- Create new custom field definitions
- Edit existing custom fields
- Select value type from supported types
- Set immutable and required flags
- Real-time validation
## Form Fields
**Required:**
- name - Unique identifier (e.g., "phone_mobile", "emergency_contact")
- value_type - Data type (:string, :integer, :boolean, :date, :email)
**Optional:**
- description - Human-readable explanation
- immutable - If true, values cannot be changed after creation (default: false)
- required - If true, all members must have this custom field (default: false)
- show_in_overview - If true, this custom field will be displayed in the member overview table (default: true)
## Value Type Selection
- `:string` - Text data (unlimited length)
- `:integer` - Numeric data
- `:boolean` - True/false flags
- `:date` - Date values
- `:email` - Validated email addresses
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update custom field)
## Security
Custom field management is restricted to admin users.
"""
use MvWeb, :live_view
@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 custom_field records in your database.")}
</:subtitle>
</.header>
<.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label={gettext("Name")} />
<.input
field={@form[:value_type]}
type="select"
label={gettext("Value type")}
options={
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
}
/>
<.input field={@form[:description]} type="text" label={gettext("Description")} />
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
<.input field={@form[:show_in_overview]} type="checkbox" label={gettext("Show in overview")} />
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom field")}
</.button>
<.button navigate={return_path(@return_to, @custom_field)}>{gettext("Cancel")}</.button>
</.form>
</Layouts.app>
"""
end
@impl true
def mount(params, _session, socket) do
custom_field =
case params["id"] do
nil -> nil
id -> Ash.get!(Mv.Membership.CustomField, id)
end
action = if is_nil(custom_field), do: "New", else: "Edit"
page_title = action <> " " <> "Custom field"
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(custom_field: custom_field)
|> assign(:page_title, page_title)
|> assign_form()}
end
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
@impl true
def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))}
end
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do
{:ok, custom_field} ->
notify_parent({:saved, custom_field})
action =
case socket.assigns.form.source.type do
:create -> gettext("create")
:update -> gettext("update")
other -> to_string(other)
end
socket =
socket
|> put_flash(:info, gettext("Custom field %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, custom_field))
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
form =
if custom_field do
AshPhoenix.Form.for_update(custom_field, :update, as: "custom_field")
else
AshPhoenix.Form.for_create(Mv.Membership.CustomField, :create, as: "custom_field")
end
assign(socket, form: to_form(form))
end
defp return_path("index", _custom_field), do: ~p"/custom_fields"
defp return_path("show", custom_field), do: ~p"/custom_fields/#{custom_field.id}"
end

View file

@ -0,0 +1,127 @@
defmodule MvWeb.CustomFieldLive.FormComponent do
@moduledoc """
LiveComponent form for creating and editing custom fields (embedded in settings).
## Features
- Create new custom field definitions
- Edit existing custom fields
- Select value type from supported types
- Set immutable and required flags
- Real-time validation
## Props
- `custom_field` - The custom field to edit (nil for new)
- `on_save` - Callback function to call when form is saved
- `on_cancel` - Callback function to call when form is cancelled
"""
use MvWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div id={@id} class="mb-8 border shadow-xl card border-base-300">
<div class="card-body">
<div class="flex items-center gap-4 mb-4">
<.button
type="button"
phx-click="cancel"
phx-target={@myself}
aria-label={gettext("Back to custom field overview")}
>
<.icon name="hero-arrow-left" class="w-4 h-4" />
</.button>
<h3 class="card-title">
{if @custom_field, do: gettext("Edit Custom Field"), else: gettext("New Custom Field")}
</h3>
</div>
<.form
for={@form}
id={@id <> "-form"}
phx-change="validate"
phx-submit="save"
phx-target={@myself}
>
<.input field={@form[:name]} type="text" label={gettext("Name")} />
<.input
field={@form[:value_type]}
type="select"
label={gettext("Value type")}
options={
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
}
/>
<.input field={@form[:description]} type="text" label={gettext("Description")} />
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
<.input
field={@form[:show_in_overview]}
type="checkbox"
label={gettext("Show in overview")}
/>
<div class="justify-end mt-4 card-actions">
<.button type="button" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom field")}
</.button>
</div>
</.form>
</div>
</div>
"""
end
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_form()}
end
@impl true
def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))}
end
@impl true
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do
{:ok, custom_field} ->
action =
case socket.assigns.form.source.type do
:create -> gettext("create")
:update -> gettext("update")
other -> to_string(other)
end
socket.assigns.on_save.(custom_field, action)
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
@impl true
def handle_event("cancel", _params, socket) do
socket.assigns.on_cancel.()
{:noreply, socket}
end
defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
form =
if custom_field do
AshPhoenix.Form.for_update(custom_field, :update, as: "custom_field")
else
AshPhoenix.Form.for_create(Mv.Membership.CustomField, :create, as: "custom_field")
end
assign(socket, form: to_form(form))
end
end

View file

@ -1,199 +0,0 @@
defmodule MvWeb.CustomFieldLive.Index do
@moduledoc """
LiveView for managing custom field definitions (admin).
## Features
- List all custom fields
- Display type information (name, value type, description)
- Show immutable and required flags
- Create new custom fields
- Edit existing custom fields
- Delete custom fields with confirmation (cascades to all custom field values)
## Displayed Information
- Name: Unique identifier for the custom field
- Value type: Data type constraint (string, integer, boolean, date, email)
- Description: Human-readable explanation
- Immutable: Whether custom field values can be changed after creation
- Required: Whether all members must have this custom field (future feature)
## Events
- `prepare_delete` - Opens deletion confirmation modal with member count
- `confirm_delete` - Executes deletion after slug verification
- `cancel_delete` - Cancels deletion and closes modal
- `update_slug_confirmation` - Updates slug input state
## Security
Custom field management is restricted to admin users.
Deletion requires entering the custom field's slug to prevent accidental deletions.
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Listing Custom fields
<:actions>
<.button variant="primary" navigate={~p"/custom_fields/new"}>
<.icon name="hero-plus" /> New Custom field
</.button>
</:actions>
</.header>
<.table
id="custom_fields"
rows={@streams.custom_fields}
row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end}
>
<:col :let={{_id, custom_field}} label="Name">{custom_field.name}</:col>
<:col :let={{_id, custom_field}} label="Description">{custom_field.description}</:col>
<:action :let={{_id, custom_field}}>
<div class="sr-only">
<.link navigate={~p"/custom_fields/#{custom_field}"}>Show</.link>
</div>
<.link navigate={~p"/custom_fields/#{custom_field}/edit"}>Edit</.link>
</:action>
<:action :let={{_id, custom_field}}>
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id})}>
Delete
</.link>
</:action>
</.table>
<%!-- Delete Confirmation Modal --%>
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg">{gettext("Delete Custom Field")}</h3>
<div class="py-4 space-y-4">
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="h-5 w-5" />
<div>
<p class="font-semibold">
{ngettext(
"%{count} member has a value assigned for this custom field.",
"%{count} members have values assigned for this custom field.",
@custom_field_to_delete.assigned_members_count,
count: @custom_field_to_delete.assigned_members_count
)}
</p>
<p class="text-sm mt-2">
{gettext(
"All custom field values will be permanently deleted when you delete this custom field."
)}
</p>
</div>
</div>
<div>
<label for="slug-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter this text:")}
</span>
</label>
<div class="font-mono font-bold text-lg mb-2 p-2 bg-base-200 rounded break-all">
{@custom_field_to_delete.slug}
</div>
<form phx-change="update_slug_confirmation">
<input
id="slug-confirmation"
name="slug"
type="text"
value={@slug_confirmation}
placeholder={gettext("Enter the text above to confirm")}
autocomplete="off"
phx-mounted={JS.focus()}
class="input input-bordered w-full"
/>
</form>
</div>
</div>
<div class="modal-action">
<button phx-click="cancel_delete" class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete"
class="btn btn-error"
disabled={@slug_confirmation != @custom_field_to_delete.slug}
>
{gettext("Delete Custom Field and All Values")}
</button>
</div>
</div>
</dialog>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Custom fields")
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")
|> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField))}
end
@impl true
def handle_event("prepare_delete", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count])
{:noreply,
socket
|> assign(:custom_field_to_delete, custom_field)
|> assign(:show_delete_modal, true)
|> assign(:slug_confirmation, "")}
end
@impl true
def handle_event("update_slug_confirmation", %{"slug" => slug}, socket) do
{:noreply, assign(socket, :slug_confirmation, slug)}
end
@impl true
def handle_event("confirm_delete", _params, socket) do
custom_field = socket.assigns.custom_field_to_delete
if socket.assigns.slug_confirmation == custom_field.slug do
# Delete the custom field (CASCADE will handle custom field values)
case Ash.destroy(custom_field) do
:ok ->
{:noreply,
socket
|> put_flash(:info, "Custom field deleted successfully")
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")
|> stream_delete(:custom_fields, custom_field)}
{:error, error} ->
{:noreply,
socket
|> put_flash(:error, "Failed to delete custom field: #{inspect(error)}")}
end
else
{:noreply,
socket
|> put_flash(:error, "Slug does not match. Deletion cancelled.")}
end
end
@impl true
def handle_event("cancel_delete", _params, socket) do
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")}
end
end

View file

@ -0,0 +1,261 @@
defmodule MvWeb.CustomFieldLive.IndexComponent do
@moduledoc """
LiveComponent for managing custom field definitions (embedded in settings).
## Features
- List all custom fields
- Display type information (name, value type, description)
- Show immutable and required flags
- Create new custom fields
- Edit existing custom fields
- Delete custom fields with confirmation (cascades to all custom field values)
"""
use MvWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div id={@id}>
<.header>
{gettext("Custom Fields")}
<:subtitle>
{gettext("These will appear in addition to other data when adding new members.")}
</:subtitle>
<:actions>
<.button variant="primary" phx-click="new_custom_field" phx-target={@myself}>
<.icon name="hero-plus" /> {gettext("New Custom field")}
</.button>
</:actions>
</.header>
<%!-- Show form when creating or editing --%>
<div :if={@show_form} class="mb-8">
<.live_component
module={MvWeb.CustomFieldLive.FormComponent}
id={@form_id}
custom_field={@editing_custom_field}
on_save={
fn custom_field, action -> send(self(), {:custom_field_saved, custom_field, action}) end
}
on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end}
/>
</div>
<%!-- Hide table when form is visible --%>
<.table
:if={!@show_form}
id="custom_fields"
rows={@streams.custom_fields}
row_click={
fn {_id, custom_field} ->
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
end
}
>
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
<:col :let={{_id, custom_field}} label={gettext("Value Type")}>
{custom_field.value_type}
</:col>
<:col :let={{_id, custom_field}} label={gettext("Description")}>
{custom_field.description}
</:col>
<:col :let={{_id, custom_field}} label={gettext("Show in Overview")}>
<span :if={custom_field.show_in_overview} class="badge badge-success">
{gettext("Yes")}
</span>
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
{gettext("No")}
</span>
</:col>
<:action :let={{_id, custom_field}}>
<.link phx-click={
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
}>
{gettext("Edit")}
</.link>
</:action>
<:action :let={{_id, custom_field}}>
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)}>
{gettext("Delete")}
</.link>
</:action>
</.table>
<%!-- Delete Confirmation Modal --%>
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Delete Custom Field")}</h3>
<div class="py-4 space-y-4">
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<div>
<p class="font-semibold">
{ngettext(
"%{count} member has a value assigned for this custom field.",
"%{count} members have values assigned for this custom field.",
@custom_field_to_delete.assigned_members_count,
count: @custom_field_to_delete.assigned_members_count
)}
</p>
<p class="mt-2 text-sm">
{gettext(
"All custom field values will be permanently deleted when you delete this custom field."
)}
</p>
</div>
</div>
<div>
<label for="slug-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter this text:")}
</span>
</label>
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
{@custom_field_to_delete.slug}
</div>
<form phx-change="update_slug_confirmation" phx-target={@myself}>
<input
id="slug-confirmation"
name="slug"
type="text"
value={@slug_confirmation}
placeholder={gettext("Enter the text above to confirm")}
autocomplete="off"
phx-mounted={JS.focus()}
class="w-full input input-bordered"
/>
</form>
</div>
</div>
<div class="modal-action">
<button phx-click="cancel_delete" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete"
phx-target={@myself}
class="btn btn-error"
disabled={@slug_confirmation != @custom_field_to_delete.slug}
>
{gettext("Delete Custom Field and All Values")}
</button>
</div>
</div>
</dialog>
</div>
"""
end
@impl true
def update(assigns, socket) do
# If show_form is explicitly provided in assigns, reset editing state
socket =
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
socket
|> assign(:editing_custom_field, nil)
|> assign(:form_id, "custom-field-form-new")
else
socket
end
{:ok,
socket
|> assign(assigns)
|> assign_new(:show_form, fn -> false end)
|> assign_new(:form_id, fn -> "custom-field-form-new" end)
|> assign_new(:editing_custom_field, fn -> nil end)
|> assign_new(:show_delete_modal, fn -> false end)
|> assign_new(:custom_field_to_delete, fn -> nil end)
|> assign_new(:slug_confirmation, fn -> "" end)
|> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField), reset: true)}
end
@impl true
def handle_event("new_custom_field", _params, socket) do
{:noreply,
socket
|> assign(:show_form, true)
|> assign(:editing_custom_field, nil)
|> assign(:form_id, "custom-field-form-new")}
end
@impl true
def handle_event("edit_custom_field", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id)
{:noreply,
socket
|> assign(:show_form, true)
|> assign(:editing_custom_field, custom_field)
|> assign(:form_id, "custom-field-form-#{id}")}
end
@impl true
def handle_event("prepare_delete", %{"id" => id}, socket) do
custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count])
{:noreply,
socket
|> assign(:custom_field_to_delete, custom_field)
|> assign(:show_delete_modal, true)
|> assign(:slug_confirmation, "")}
end
@impl true
def handle_event("update_slug_confirmation", %{"slug" => slug}, socket) do
{:noreply, assign(socket, :slug_confirmation, slug)}
end
@impl true
def handle_event("confirm_delete", _params, socket) do
custom_field = socket.assigns.custom_field_to_delete
if socket.assigns.slug_confirmation == custom_field.slug do
case Ash.destroy(custom_field) do
:ok ->
send(self(), {:custom_field_deleted, custom_field})
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")
|> stream_delete(:custom_fields, custom_field)}
{:error, error} ->
send(self(), {:custom_field_delete_error, error})
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")}
end
else
send(self(), :custom_field_slug_mismatch)
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")}
end
end
@impl true
def handle_event("cancel_delete", _params, socket) do
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")}
end
end

View file

@ -1,75 +0,0 @@
defmodule MvWeb.CustomFieldLive.Show do
@moduledoc """
LiveView for displaying a single custom field's details (admin).
## Features
- Display custom field definition
- Show all attributes (name, value type, description, flags)
- Navigate to edit form
- Return to custom field list
## Displayed Information
- ID: Internal UUID identifier
- Slug: URL-friendly identifier (auto-generated, immutable)
- Name: Unique identifier
- Value type: Data type constraint
- Description: Optional explanation
- Immutable flag: Whether values can be changed
- Required flag: Whether all members need this custom field
## Navigation
- Back to custom field list
- Edit custom field
## Security
Custom field details are restricted to admin users.
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
Custom field {@custom_field.slug}
<:subtitle>This is a custom_field record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/custom_fields"}>
<.icon name="hero-arrow-left" />
</.button>
<.button
variant="primary"
navigate={~p"/custom_fields/#{@custom_field}/edit?return_to=show"}
>
<.icon name="hero-pencil-square" /> Edit Custom field
</.button>
</:actions>
</.header>
<.list>
<:item title="Id">{@custom_field.id}</:item>
<:item title="Slug">
{@custom_field.slug}
<p class="mt-2 text-sm leading-6 text-zinc-600">
{gettext("Auto-generated identifier (immutable)")}
</p>
</:item>
<:item title="Name">{@custom_field.name}</:item>
<:item title="Description">{@custom_field.description}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Show Custom field")
|> assign(:custom_field, Ash.get!(Mv.Membership.CustomField, id))}
end
end

View file

@ -4,6 +4,7 @@ defmodule MvWeb.GlobalSettingsLive do
## Features
- Edit the association/club name
- Manage custom fields
- Real-time form validation
- Success/error feedback
@ -28,7 +29,7 @@ defmodule MvWeb.GlobalSettingsLive do
{:ok,
socket
|> assign(:page_title, gettext("Club Settings"))
|> assign(:page_title, gettext("Settings"))
|> assign(:settings, settings)
|> assign_form()}
end
@ -38,12 +39,16 @@ defmodule MvWeb.GlobalSettingsLive do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Club Settings")}
{gettext("Settings")}
<:subtitle>
{gettext("Manage global settings for the association.")}
</:subtitle>
</.header>
<%!-- Club Settings Section --%>
<.header>
{gettext("Club Settings")}
</.header>
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
<.input
field={@form[:club_name]}
@ -56,6 +61,12 @@ defmodule MvWeb.GlobalSettingsLive do
{gettext("Save Settings")}
</.button>
</.form>
<%!-- Custom Fields Section --%>
<.live_component
module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component"
/>
</Layouts.app>
"""
end
@ -66,6 +77,7 @@ defmodule MvWeb.GlobalSettingsLive do
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
end
@impl true
def handle_event("save", %{"setting" => setting_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do
{:ok, updated_settings} ->
@ -82,6 +94,37 @@ defmodule MvWeb.GlobalSettingsLive do
end
end
@impl true
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent,
id: "custom-fields-component",
show_form: false
)
{:noreply,
put_flash(socket, :info, gettext("Custom field %{action} successfully", action: action))}
end
@impl true
def handle_info({:custom_field_deleted, _custom_field}, socket) do
{:noreply, put_flash(socket, :info, gettext("Custom field deleted successfully"))}
end
@impl true
def handle_info({:custom_field_delete_error, error}, socket) do
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to delete custom field: %{error}", error: inspect(error))
)}
end
@impl true
def handle_info(:custom_field_slug_mismatch, socket) do
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
end
defp assign_form(%{assigns: %{settings: settings}} = socket) do
form =
AshPhoenix.Form.for_update(

View file

@ -5,81 +5,212 @@ defmodule MvWeb.MemberLive.Form do
## Features
- Create new members with personal information
- Edit existing member details
- Manage custom properties (dynamic fields)
- Grouped sections for better organization
- Tab navigation (Payments tab disabled, coming soon)
- Manage custom properties (dynamic fields, displayed sorted by name)
- Real-time validation with visual feedback
- Link/unlink user accounts
## Form Fields
**Required:**
- first_name, last_name, email
**Optional:**
- birth_date, phone_number, address fields (city, street, house_number, postal_code)
- join_date, exit_date
- paid status
- notes
## Custom Field Values
Members can have dynamic custom field values defined by CustomFields.
The form dynamically renders inputs based on available CustomFields.
## Form Sections
- Personal Data: Name, address, contact information, membership dates, notes
- Custom Fields: Dynamic fields in uniform grid layout (displayed sorted by name)
- Payment Data: Mockup section (not editable)
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update member)
- Custom field value management events for adding/removing custom fields
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
# Sort custom fields by name for display only
sorted_custom_fields = Enum.sort_by(assigns.custom_fields, & &1.name)
assigns = assign(assigns, :sorted_custom_fields, sorted_custom_fields)
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>
{gettext("Use this form to manage member records and their properties.")}
</:subtitle>
</.header>
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
<.input field={@form[:first_name]} label={gettext("First Name")} required />
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
<.input field={@form[:birth_date]} label={gettext("Birth Date")} type="date" />
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
<.input field={@form[:phone_number]} label={gettext("Phone Number")} />
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
<.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" />
<.input field={@form[:notes]} label={gettext("Notes")} />
<.input field={@form[:city]} label={gettext("City")} />
<.input field={@form[:street]} label={gettext("Street")} />
<.input field={@form[:house_number]} label={gettext("House Number")} />
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
<%!-- Header with Back button, Name display, and Save button --%>
<div class="flex items-center justify-between gap-4 pb-4">
<.button navigate={return_path(@return_to, @member)} type="button">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
<.inputs_for :let={f_custom_field_value} field={@form[:custom_field_values]}>
<% type =
Enum.find(@custom_fields, &(&1.id == f_custom_field_value[:custom_field_id].value)) %>
<.inputs_for :let={value_form} field={f_custom_field_value[:value]}>
<% input_type =
cond do
type && type.value_type == :boolean -> "checkbox"
type && type.value_type == :date -> :date
true -> :text
end %>
<.input field={value_form[:value]} label={type && type.name} type={input_type} />
</.inputs_for>
<input
type="hidden"
name={f_custom_field_value[:custom_field_id].name}
value={f_custom_field_value[:custom_field_id].value}
/>
</.inputs_for>
<h1 class="text-2xl font-bold text-center flex-1">
<%= if @member do %>
{@member.first_name} {@member.last_name}
<% else %>
{gettext("New Member")}
<% end %>
</h1>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Member")}
</.button>
<.button navigate={return_path(@return_to, @member)}>{gettext("Cancel")}</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</div>
<%!-- Tab Navigation --%>
<div role="tablist" class="tabs tabs-bordered mb-6">
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
<.icon name="hero-identification" class="size-4 mr-2" />
{gettext("Contact Data")}
</button>
<button
type="button"
role="tab"
class="tab"
disabled
aria-disabled="true"
title={gettext("Coming soon")}
>
<.icon name="hero-credit-card" class="size-4 mr-2" />
{gettext("Payments")}
</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>
<.form_section title={gettext("Personal Data")}>
<div class="space-y-4">
<%!-- Name Row --%>
<div class="flex gap-4">
<div class="w-48">
<.input field={@form[:first_name]} label={gettext("First Name")} required />
</div>
<div class="w-48">
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
</div>
</div>
<%!-- Address Row --%>
<div class="flex gap-4">
<div class="flex-1">
<.input field={@form[:street]} label={gettext("Street")} />
</div>
<div class="w-16">
<.input field={@form[:house_number]} label={gettext("Nr.")} />
</div>
<div class="w-24">
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
</div>
<div class="w-32">
<.input field={@form[:city]} label={gettext("City")} />
</div>
</div>
<%!-- Email --%>
<div>
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
</div>
<%!-- Phone --%>
<div>
<.input field={@form[:phone_number]} label={gettext("Phone")} type="tel" />
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-4">
<div class="w-36">
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
</div>
<div class="w-36">
<.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" />
</div>
</div>
<%!-- Notes --%>
<div>
<.input field={@form[:notes]} label={gettext("Notes")} type="textarea" />
</div>
</div>
</.form_section>
</div>
<%!-- Custom Fields Section --%>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.form_section title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
<%= for cf <- @sorted_custom_fields do %>
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
<%= if f_cfv[:custom_field_id].value == cf.id do %>
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
<.inputs_for :let={value_form} field={f_cfv[:value]}>
<.input
field={value_form[:value]}
label={cf.name}
type={custom_field_input_type(cf.value_type)}
/>
</.inputs_for>
<input
type="hidden"
name={f_cfv[:custom_field_id].name}
value={f_cfv[:custom_field_id].value}
/>
</div>
<% end %>
</.inputs_for>
<% end %>
</div>
</.form_section>
</div>
<% end %>
</div>
<%!-- Payment Data Section (Mockup) --%>
<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")}
</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" />
</div>
</div>
</.form_section>
</div>
<%!-- Bottom Action Buttons --%>
<div class="flex justify-end gap-4 mt-6">
<.button navigate={return_path(@return_to, @member)} type="button">
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Member")}
</.button>
</div>
</.form>
</Layouts.app>
"""
@ -107,8 +238,8 @@ defmodule MvWeb.MemberLive.Form do
id -> Ash.get!(Mv.Membership.Member, id)
end
action = if is_nil(member), do: "New", else: "Edit"
page_title = action <> " " <> "Member"
page_title =
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
{:ok,
socket
@ -214,5 +345,37 @@ defmodule MvWeb.MemberLive.Form do
end
defp return_path("index", _member), do: ~p"/members"
defp return_path("show", nil), do: ~p"/members"
defp return_path("show", member), do: ~p"/members/#{member.id}"
# -----------------------------------------------------------------
# Helper Components
# -----------------------------------------------------------------
# Renders a form section box with border and title.
attr :title, :string, required: true
slot :inner_block, required: true
defp form_section(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
# -----------------------------------------------------------------
# Helper Functions for Custom Fields
# -----------------------------------------------------------------
# Returns input type for custom field based on value type
defp custom_field_input_type(:string), do: "text"
defp custom_field_input_type(:integer), do: "number"
defp custom_field_input_type(:boolean), do: "checkbox"
defp custom_field_input_type(:date), do: "date"
defp custom_field_input_type(:email), do: "email"
defp custom_field_input_type(_), do: "text"
end

View file

@ -18,6 +18,7 @@ defmodule MvWeb.MemberLive.Index do
- `delete` - Remove a member from the database
- `select_member` - Toggle individual member selection
- `select_all` - Toggle selection of all visible members
- `copy_emails` - Copy email addresses of selected members to clipboard
## Implementation Notes
- Search uses PostgreSQL full-text search (plainto_tsquery)
@ -29,21 +30,30 @@ defmodule MvWeb.MemberLive.Index do
require Ash.Query
import Ash.Expr
alias Mv.Membership
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix "custom_field_"
@custom_field_prefix Mv.Constants.custom_field_prefix()
# Member fields that are loaded for the overview
# Uses constants from Mv.Constants to ensure consistency
# Note: :id is always included for member identification
# All member fields are loaded, but visibility is controlled via settings
@overview_fields [:id | Mv.Constants.member_fields()]
@doc """
Initializes the LiveView state.
Sets up initial assigns for page title, search query, sort configuration,
and member selection. Actual data loading happens in `handle_params/3`.
Reads sorting preferences from cookies if available.
payment filter, and member selection. Actual data loading happens in `handle_params/3`.
"""
@impl true
def mount(_params, _session, socket) do
# Load custom fields that should be shown in overview
def mount(_params, session, socket) do
# Load custom fields that should be shown in overview (for display)
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView
# and result in a 500 error page. This is appropriate for LiveViews where errors
# should be visible to the user rather than silently failing.
@ -53,17 +63,51 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
# Read sorting preferences from cookies (sent via connect_params)
{sort_field, sort_order} = get_sort_from_cookies(socket)
# Load ALL custom fields for the dropdown (to show all available fields)
all_custom_fields =
Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
# Load settings once to avoid N+1 queries
settings =
case Membership.get_settings() do
{:ok, s} -> s
# Fallback if settings can't be loaded
{:error, _} -> %{member_field_visibility: %{}}
end
# Load user field selection from session
session_selection = FieldSelection.get_from_session(session)
# Get all available fields (for dropdown - includes ALL custom fields)
all_available_fields = FieldVisibility.get_all_available_fields(all_custom_fields)
# Merge session selection with global settings for initial state (use all_custom_fields)
initial_selection =
FieldVisibility.merge_with_global_settings(
session_selection,
settings,
all_custom_fields
)
socket =
socket
|> assign(:page_title, gettext("Members"))
|> assign(:query, "")
|> assign_new(:sort_field, fn -> sort_field end)
|> assign_new(:sort_order, fn -> sort_order end)
|> assign(:selected_members, [])
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:paid_filter, nil)
|> assign(:selected_members, MapSet.new())
|> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:all_custom_fields, all_custom_fields)
|> assign(:all_available_fields, all_available_fields)
|> assign(:user_field_selection, initial_selection)
|> assign(
:member_fields_visible,
FieldVisibility.get_visible_member_fields(initial_selection)
)
# We call handle params to use the query from the URL
{:ok, socket}
@ -95,10 +139,10 @@ defmodule MvWeb.MemberLive.Index do
@impl true
def handle_event("select_member", %{"id" => id}, socket) do
selected =
if id in socket.assigns.selected_members do
List.delete(socket.assigns.selected_members, id)
if MapSet.member?(socket.assigns.selected_members, id) do
MapSet.delete(socket.assigns.selected_members, id)
else
[id | socket.assigns.selected_members]
MapSet.put(socket.assigns.selected_members, id)
end
{:noreply, assign(socket, :selected_members, selected)}
@ -106,13 +150,11 @@ defmodule MvWeb.MemberLive.Index do
@impl true
def handle_event("select_all", _params, socket) do
members = socket.assigns.members
all_ids = Enum.map(members, & &1.id)
all_ids = socket.assigns.members |> Enum.map(& &1.id) |> MapSet.new()
selected =
if Enum.sort(socket.assigns.selected_members) == Enum.sort(all_ids) do
[]
if MapSet.equal?(socket.assigns.selected_members, all_ids) do
MapSet.new()
else
all_ids
end
@ -120,6 +162,46 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, assign(socket, :selected_members, selected)}
end
@impl true
def handle_event("copy_emails", _params, socket) do
selected_ids = socket.assigns.selected_members
# Filter members that are in the selection and have email addresses
formatted_emails = format_selected_member_emails(socket.assigns.members, selected_ids)
email_count = length(formatted_emails)
cond do
MapSet.size(selected_ids) == 0 ->
{:noreply, put_flash(socket, :error, gettext("No members selected"))}
email_count == 0 ->
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
true ->
# RFC 5322 uses comma as separator for email address lists
email_string = Enum.join(formatted_emails, ", ")
socket =
socket
|> push_event("copy_to_clipboard", %{text: email_string})
|> put_flash(
:success,
ngettext(
"Copied %{count} email address to clipboard",
"Copied %{count} email addresses to clipboard",
email_count,
count: email_count
)
)
|> put_flash(
:warning,
gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
)
{:noreply, socket}
end
end
# -----------------------------------------------------------------
# Handle Infos from Child Components
# -----------------------------------------------------------------
@ -130,6 +212,8 @@ defmodule MvWeb.MemberLive.Index do
## Supported messages:
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
- `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
"""
@impl true
def handle_info({:sort, field_str}, socket) do
@ -143,31 +227,24 @@ defmodule MvWeb.MemberLive.Index do
{new_field, new_order} = determine_new_sort(field, socket)
socket =
socket
|> update_sort_components(socket.assigns.sort_field, new_field, new_order)
# Persist sorting to cookie via JavaScript hook
|> push_event("persist_sort", %{
sort_field: new_field,
sort_order: new_order
})
push_sort_url(socket, new_field, new_order)
socket
|> update_sort_components(socket.assigns.sort_field, new_field, new_order)
|> push_sort_url(new_field, new_order)
end
@impl true
def handle_info({:search_changed, q}, socket) do
socket = load_members(socket, q)
socket =
socket
|> assign(:query, q)
|> load_members()
existing_field_query = socket.assigns.sort_field
existing_sort_query = socket.assigns.sort_order
# Build the URL with queries
query_params = %{
"query" => q,
"sort_field" => existing_field_query,
"sort_order" => existing_sort_query
}
query_params =
build_query_params(q, existing_field_query, existing_sort_query, socket.assigns.paid_filter)
# Set the new path with params
new_path = ~p"/members?#{query_params}"
@ -180,23 +257,137 @@ defmodule MvWeb.MemberLive.Index do
)}
end
@impl true
def handle_info({:payment_filter_changed, filter}, socket) do
socket =
socket
|> assign(:paid_filter, filter)
|> load_members()
# Build the URL with all params including new filter
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
filter
)
new_path = ~p"/members?#{query_params}"
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
@impl true
def handle_info({:field_toggled, field_string, visible}, socket) do
# Update user field selection
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
# Save to session (cookie will be saved on next page load via handle_params)
socket = update_session_field_selection(socket, new_selection)
# Merge with global settings (use all_custom_fields to allow enabling globally hidden fields)
final_selection =
FieldVisibility.merge_with_global_settings(
new_selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
# Get visible fields
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
|> push_field_selection_url()
{:noreply, socket}
end
@impl true
def handle_info({:fields_selected, selection}, socket) do
# Save to session
socket = update_session_field_selection(socket, selection)
# Merge with global settings (use all_custom_fields for merging)
final_selection =
FieldVisibility.merge_with_global_settings(
selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
# Get visible fields
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
|> push_field_selection_url()
{:noreply, socket}
end
# -----------------------------------------------------------------
# Handle Params from the URL
# -----------------------------------------------------------------
@doc """
Handles URL parameter changes.
Parses query parameters for search query, sort field, and sort order,
Parses query parameters for search query, sort field, sort order, and payment filter, and field selection,
then loads members accordingly. This enables bookmarkable URLs and
browser back/forward navigation.
"""
@impl true
def handle_params(params, _url, socket) do
# Parse field selection from URL
url_selection = FieldSelection.parse_from_url(params)
# Merge with session selection (URL has priority)
merged_selection =
FieldSelection.merge_sources(
url_selection,
socket.assigns.user_field_selection,
%{}
)
# Merge with global settings (use all_custom_fields for merging)
final_selection =
FieldVisibility.merge_with_global_settings(
merged_selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
# Get visible fields
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> load_members(params["query"])
|> maybe_update_paid_filter(params)
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
{:noreply, socket}
@ -208,10 +399,17 @@ defmodule MvWeb.MemberLive.Index do
# - `:custom_field` - The CustomField resource
# - `:render` - A function that formats the custom field value for a given member
#
# Only includes custom fields that are visible according to user field selection.
#
# Returns the socket with `:dynamic_cols` assigned.
defp prepare_dynamic_cols(socket) do
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
# Use all_custom_fields to allow users to enable globally hidden custom fields
dynamic_cols =
Enum.map(socket.assigns.custom_fields_visible, fn custom_field ->
socket.assigns.all_custom_fields
|> Enum.filter(fn custom_field -> custom_field.id in visible_custom_field_ids end)
|> Enum.map(fn custom_field ->
%{
custom_field: custom_field,
render: fn member ->
@ -287,11 +485,13 @@ defmodule MvWeb.MemberLive.Index do
field
end
query_params = %{
"query" => socket.assigns.query,
"sort_field" => field_str,
"sort_order" => Atom.to_string(order)
}
query_params =
build_query_params(
socket.assigns.query,
field_str,
Atom.to_string(order),
socket.assigns.paid_filter
)
new_path = ~p"/members?#{query_params}"
@ -302,13 +502,97 @@ defmodule MvWeb.MemberLive.Index do
)}
end
# Loads members from the database with custom field values and applies search/sort filters.
# 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
defp maybe_add_field_selection(params, selection) when is_map(selection) do
fields_param = FieldSelection.to_url_param(selection)
if fields_param != "", do: Map.put(params, "fields", fields_param), else: params
end
defp maybe_add_field_selection(params, _), do: params
# 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)
}
# 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
# This is a placeholder for future session persistence
assign(socket, :user_field_selection, selection)
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
field_str =
if is_atom(sort_field) do
Atom.to_string(sort_field)
else
sort_field
end
order_str =
if is_atom(sort_order) do
Atom.to_string(sort_order)
else
sort_order
end
base_params = %{
"query" => query,
"sort_field" => field_str,
"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")
end
end
# Loads members from the database with custom field values and applies search/sort/payment filters.
#
# Process:
# 1. Builds base query with selected fields
# 2. Loads custom field values for visible custom fields (filtered at database level)
# 3. Applies search filter if provided
# 4. Applies sorting (database-level for regular fields, in-memory for custom fields)
# 4. Applies payment status filter if set
# 5. Applies sorting (database-level for regular fields, in-memory for custom fields)
#
# Performance Considerations:
# - Database-level filtering: Custom field values are filtered directly in the database
@ -320,30 +604,24 @@ defmodule MvWeb.MemberLive.Index do
# consider implementing pagination (see Issue #165).
#
# Returns the socket with `:members` assigned.
defp load_members(socket, search_query) do
defp load_members(socket) do
search_query = socket.assigns.query
query =
Mv.Membership.Member
|> Ash.Query.new()
|> Ash.Query.select([
:id,
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
])
|> Ash.Query.select(@overview_fields)
# Load custom field values for visible custom fields
custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id)
query = load_custom_field_values(query, custom_field_ids_list)
# Load custom field values for visible custom fields (based on user selection)
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
query = load_custom_field_values(query, visible_custom_field_ids)
# 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} =
@ -418,6 +696,24 @@ defmodule MvWeb.MemberLive.Index do
end
end
# Applies payment status filter to the query.
#
# 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
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)))
end
# Functions to toggle sorting order
defp toggle_order(:asc), do: :desc
defp toggle_order(:desc), do: :asc
@ -444,18 +740,13 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_sort(query, _, _, _), do: {query, false}
# Validate that a field is sortable
# Uses member fields from constants, but excludes fields that don't make sense to sort
# (e.g., :notes is too long, :paid is boolean and not very useful for sorting)
defp valid_sort_field?(field) when is_atom(field) do
valid_fields = [
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
]
# 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]
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
field in valid_fields or custom_field_sort?(field)
end
@ -501,6 +792,18 @@ defmodule MvWeb.MemberLive.Index do
defp extract_custom_field_id(_), do: nil
# Extracts custom field IDs from visible custom field strings
# Format: "custom_field_<id>" -> <id>
defp extract_custom_field_ids(visible_custom_fields) do
Enum.map(visible_custom_fields, fn field_string ->
case String.split(field_string, @custom_field_prefix) do
["", id] -> id
_ -> nil
end
end)
|> Enum.filter(&(&1 != nil))
end
# Sorts members in memory by a custom field value.
#
# Process:
@ -713,6 +1016,29 @@ defmodule MvWeb.MemberLive.Index do
socket
end
# Updates paid 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)
end
defp maybe_update_paid_filter(socket, _params) do
# Reset filter if not in URL params
assign(socket, :paid_filter, nil)
end
# Determines valid paid filter from URL parameter.
#
# SECURITY: This function whitelists allowed filter values. Only "paid" and "not_paid"
# 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
# -------------------------------------------------------------
# Helper Functions for Custom Field Values
# -------------------------------------------------------------
@ -745,55 +1071,35 @@ defmodule MvWeb.MemberLive.Index do
end
end
# Retrieves sorting preferences from cookies.
#
# Reads the member_sort_field and member_sort_order from connect_params (sent by JavaScript).
# Falls back to default values (:first_name, :asc) if cookies are not present or invalid.
#
# Parameters:
# - `socket` - The LiveView socket containing connect_params
#
# Returns:
# - `{field, order}` tuple where field is an atom or string, and order is :asc or :desc
defp get_sort_from_cookies(socket) do
connect_params = get_connect_params(socket) || %{}
sort_field_str = connect_params["member_sort_field"]
sort_order_str = connect_params["member_sort_order"]
field = parse_and_validate_field(sort_field_str)
order = parse_and_validate_order(sort_order_str)
{field, order}
# Filters selected members with email addresses and formats them.
# Returns a list of formatted email strings in the format "First Last <email>".
# Used by both copy_emails and mailto links.
def format_selected_member_emails(members, selected_members) do
members
|> Enum.filter(fn member ->
MapSet.member?(selected_members, member.id) && member.email && member.email != ""
end)
|> Enum.map(&format_member_email/1)
end
# Parses and validates a sort field with configurable default.
#
# Handles both regular member fields (converted to atoms) and custom fields
# (kept as strings). Validates against allowed sort fields.
#
# Parameters:
# - `field` - Field to parse (string or nil)
# - `default` - Default field to use if parsing fails (default: :first_name)
#
# Returns a valid sort field (atom or string for custom fields).
defp parse_and_validate_field(field, default \\ :first_name)
# Formats a member's email in the format "First Last <email>"
# Used for copy_emails feature and mailto links to create email-client-friendly format.
def format_member_email(member) do
first_name = member.first_name || ""
last_name = member.last_name || ""
defp parse_and_validate_field(field_str, default) when is_binary(field_str) do
if valid_sort_field?(field_str), do: field_str, else: default
name =
[first_name, last_name]
|> Enum.filter(&(&1 != ""))
|> Enum.join(" ")
if name == "" do
member.email
else
"#{name} <#{member.email}>"
end
end
defp parse_and_validate_field(_, default), do: default
# Parses and validates a sort order with configurable default.
#
# Ensures the order is either :asc or :desc.
#
# Parameters:
# - `order` - Order to parse, as a string or nil
#
# Returns `:asc` or `:desc`.
defp parse_and_validate_order("asc"), do: :asc
defp parse_and_validate_order("desc"), do: :desc
defp parse_and_validate_order(_), do: :asc
# Public helper function to format dates for use in templates
def format_date(date), do: DateFormatter.format_date(date)
end

View file

@ -1,210 +1,280 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<div id="member-sort-persistence" phx-hook="MemberSortPersistence">
<.header>
{gettext("Members")}
<:actions>
<.button variant="primary" navigate={~p"/members/new"}>
<.icon name="hero-plus" /> {gettext("New Member")}
</.button>
</:actions>
</.header>
<.header>
{gettext("Members")}
<:actions>
<.button
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
id="copy-emails-btn"
phx-hook="CopyToClipboard"
phx-click="copy_emails"
aria-label={gettext("Copy email addresses of selected members")}
>
<.icon name="hero-clipboard-document" />
{gettext("Copy emails")} ({Enum.count(@members, &MapSet.member?(@selected_members, &1.id))})
</.button>
<.button
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
href={
"mailto:?bcc=" <>
(MvWeb.MemberLive.Index.format_selected_member_emails(@members, @selected_members)
|> Enum.join(", ")
|> URI.encode())
}
aria-label={gettext("Open email program with BCC recipients")}
>
<.icon name="hero-envelope" />
{gettext("Open in email program")}
</.button>
<.button variant="primary" navigate={~p"/members/new"}>
<.icon name="hero-plus" /> {gettext("New Member")}
</.button>
</:actions>
</.header>
<div class="flex flex-wrap gap-4 items-center">
<.live_component
module={MvWeb.Components.SearchBarComponent}
id="search-bar"
query={@query}
placeholder={gettext("Search...")}
/>
<.live_component
module={MvWeb.Components.PaymentFilterComponent}
id="payment-filter"
paid_filter={@paid_filter}
member_count={length(@members)}
/>
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown"
all_fields={@all_available_fields}
custom_fields={@all_custom_fields}
selected_fields={@user_field_selection}
/>
</div>
<.table
id="members"
rows={@members}
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
dynamic_cols={@dynamic_cols}
sort_field={@sort_field}
sort_order={@sort_order}
>
<.table
id="members"
rows={@members}
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
dynamic_cols={@dynamic_cols}
sort_field={@sort_field}
sort_order={@sort_order}
>
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
<:col
:let={member}
label={
~H"""
<.input
type="checkbox"
name="select_all"
phx-click="select_all"
checked={Enum.sort(@selected_members) == Enum.map(@members, & &1.id) |> Enum.sort()}
aria-label={gettext("Select all members")}
role="checkbox"
/>
"""
}
>
<:col
:let={member}
label={
~H"""
<.input
type="checkbox"
name={member.id}
phx-click="select_member"
phx-value-id={member.id}
checked={member.id in @selected_members}
phx-capture-click
phx-stop-propagation
aria-label={gettext("Select member")}
name="select_all"
phx-click="select_all"
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
aria-label={gettext("Select all members")}
role="checkbox"
/>
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_first_name}
field={:first_name}
label={gettext("First name")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.first_name} {member.last_name}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_email}
field={:email}
label={gettext("Email")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.email}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_street}
field={:street}
label={gettext("Street")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.street}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_house_number}
field={:house_number}
label={gettext("House Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.house_number}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_postal_code}
field={:postal_code}
label={gettext("Postal Code")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.postal_code}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_city}
field={:city}
label={gettext("City")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.city}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_phone_number}
field={:phone_number}
label={gettext("Phone Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.phone_number}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_join_date}
field={:join_date}
label={gettext("Join Date")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.join_date}
</:col>
<:action :let={member}>
<div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
</div>
"""
}
>
<.input
type="checkbox"
name={member.id}
phx-click="select_member"
phx-value-id={member.id}
checked={MapSet.member?(@selected_members, member.id)}
phx-capture-click
phx-stop-propagation
aria-label={gettext("Select member")}
role="checkbox"
/>
</:col>
<:col
:let={member}
:if={:first_name in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_first_name}
field={:first_name}
label={gettext("First name")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.first_name}
</:col>
<:col
:let={member}
:if={:last_name in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_last_name}
field={:last_name}
label={gettext("Last name")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.last_name}
</:col>
<:col
:let={member}
:if={:email in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_email}
field={:email}
label={gettext("Email")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.email}
</:col>
<:col
:let={member}
:if={:street in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_street}
field={:street}
label={gettext("Street")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.street}
</:col>
<:col
:let={member}
:if={:house_number in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_house_number}
field={:house_number}
label={gettext("House Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.house_number}
</:col>
<:col
:let={member}
:if={:postal_code in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_postal_code}
field={:postal_code}
label={gettext("Postal Code")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.postal_code}
</:col>
<:col
:let={member}
:if={:city in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_city}
field={:city}
label={gettext("City")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.city}
</:col>
<:col
:let={member}
:if={:phone_number in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_phone_number}
field={:phone_number}
label={gettext("Phone Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.phone_number}
</:col>
<:col
:let={member}
:if={:join_date in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_join_date}
field={:join_date}
label={gettext("Join Date")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{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>
<:action :let={member}>
<div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
</div>
<.link navigate={~p"/members/#{member}/edit"}>{gettext("Edit")}</.link>
</:action>
<.link navigate={~p"/members/#{member}/edit"}>{gettext("Edit")}</.link>
</:action>
<:action :let={member}>
<.link
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
data-confirm={gettext("Are you sure?")}
>
{gettext("Delete")}
</.link>
</:action>
</.table>
</div>
<:action :let={member}>
<.link
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
data-confirm={gettext("Are you sure?")}
>
{gettext("Delete")}
</.link>
</:action>
</.table>
</Layouts.app>

View file

@ -0,0 +1,231 @@
defmodule MvWeb.MemberLive.Index.FieldSelection do
@moduledoc """
Handles user-specific field selection persistence and URL parameter parsing.
This module manages:
- Reading/writing field selection from cookies (persistent storage)
- Reading/writing field selection from session (temporary storage)
- Parsing field selection from URL parameters
- Merging multiple sources with priority: URL > Session > Cookie
## Data Format
Field selection is stored as a map:
```elixir
%{
"first_name" => true,
"email" => true,
"street" => false,
"custom_field_abc-123" => true
}
```
## Cookie/Session Format
Stored as JSON string: `{"first_name":true,"email":true}`
## URL Format
Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
"""
@cookie_name "member_field_selection"
@cookie_max_age 365 * 24 * 60 * 60
@session_key "member_field_selection"
@doc """
Reads field selection from session.
Returns a map of field names (strings) to boolean visibility values.
Returns empty map if no selection is stored.
"""
@spec get_from_session(map()) :: %{String.t() => boolean()}
def get_from_session(session) when is_map(session) do
case Map.get(session, @session_key) do
nil -> %{}
json_string when is_binary(json_string) -> parse_json(json_string)
_ -> %{}
end
end
def get_from_session(_), do: %{}
@doc """
Saves field selection to session.
Converts the map to JSON string and stores it in the session.
"""
@spec save_to_session(map(), %{String.t() => boolean()}) :: map()
def save_to_session(session, selection) when is_map(selection) do
json_string = Jason.encode!(selection)
Map.put(session, @session_key, json_string)
end
def save_to_session(session, _), do: session
@doc """
Reads field selection from cookie.
Returns a map of field names (strings) to boolean visibility values.
Returns empty map if no cookie is present.
Note: This function parses the raw Cookie header. In LiveView, cookies
are typically accessed via get_connect_info.
"""
@spec get_from_cookie(Plug.Conn.t()) :: %{String.t() => boolean()}
def get_from_cookie(conn) do
# get_req_header always returns a list ([] if no header, [value] if present)
case Plug.Conn.get_req_header(conn, "cookie") do
[] ->
%{}
[cookie_header | _rest] ->
cookies = parse_cookie_header(cookie_header)
case Map.get(cookies, @cookie_name) do
nil -> %{}
json_string when is_binary(json_string) -> parse_json(json_string)
_ -> %{}
end
end
end
# Parses cookie header string into a map
defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do
cookie_header
|> String.split(";")
|> Enum.map(&String.trim/1)
|> Enum.map(&String.split(&1, "=", parts: 2))
|> Enum.reduce(%{}, fn
[key, value], acc -> Map.put(acc, key, URI.decode(value))
[key], acc -> Map.put(acc, key, "")
_, acc -> acc
end)
end
defp parse_cookie_header(_), do: %{}
@doc """
Saves field selection to cookie.
Sets a persistent cookie with the field selection as JSON.
"""
@spec save_to_cookie(Plug.Conn.t(), %{String.t() => boolean()}) :: Plug.Conn.t()
def save_to_cookie(conn, selection) when is_map(selection) do
json_string = Jason.encode!(selection)
secure = Application.get_env(:mv, :use_secure_cookies, false)
Plug.Conn.put_resp_cookie(conn, @cookie_name, json_string,
max_age: @cookie_max_age,
same_site: "Lax",
http_only: true,
secure: secure
)
end
def save_to_cookie(conn, _), do: conn
@doc """
Parses field selection from URL parameters.
Expects a comma-separated list of field names in the `fields` parameter.
All fields in the list are set to `true` (visible).
## Examples
iex> parse_from_url(%{"fields" => "first_name,email"})
%{"first_name" => true, "email" => true}
iex> parse_from_url(%{"fields" => "custom_field_abc-123"})
%{"custom_field_abc-123" => true}
iex> parse_from_url(%{})
%{}
"""
@spec parse_from_url(map()) :: %{String.t() => boolean()}
def parse_from_url(params) when is_map(params) do
case Map.get(params, "fields") do
nil -> %{}
"" -> %{}
fields_string when is_binary(fields_string) -> parse_fields_string(fields_string)
_ -> %{}
end
end
def parse_from_url(_), do: %{}
@doc """
Merges multiple field selection sources with priority.
Priority order (highest to lowest):
1. URL parameters
2. Session
3. Cookie
Later sources override earlier ones for the same field.
## Examples
iex> merge_sources(%{"first_name" => true}, %{"email" => true}, %{"street" => true})
%{"first_name" => true, "email" => true, "street" => true}
iex> merge_sources(%{"first_name" => false}, %{"first_name" => true}, %{})
%{"first_name" => false} # URL has priority
"""
@spec merge_sources(
%{String.t() => boolean()},
%{String.t() => boolean()},
%{String.t() => boolean()}
) :: %{String.t() => boolean()}
def merge_sources(url_selection, session_selection, cookie_selection) do
%{}
|> Map.merge(cookie_selection)
|> Map.merge(session_selection)
|> Map.merge(url_selection)
end
@doc """
Converts field selection map to URL parameter string.
Returns a comma-separated string of visible fields (where value is `true`).
## Examples
iex> to_url_param(%{"first_name" => true, "email" => true, "street" => false})
"first_name,email"
"""
@spec to_url_param(%{String.t() => boolean()}) :: String.t()
def to_url_param(selection) when is_map(selection) do
selection
|> Enum.filter(fn {_field, visible} -> visible end)
|> Enum.map_join(",", fn {field, _visible} -> field end)
end
def to_url_param(_), do: ""
# Parses a JSON string into a map, handling errors gracefully
defp parse_json(json_string) when is_binary(json_string) do
case Jason.decode(json_string) do
{:ok, decoded} when is_map(decoded) ->
# Ensure all values are booleans
Enum.reduce(decoded, %{}, fn
{key, value}, acc when is_boolean(value) -> Map.put(acc, key, value)
{key, _value}, acc -> Map.put(acc, key, true)
end)
_ ->
%{}
end
end
defp parse_json(_), do: %{}
# Parses a comma-separated string of field names
defp parse_fields_string(fields_string) do
fields_string
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.filter(&(&1 != ""))
|> Enum.reduce(%{}, fn field, acc -> Map.put(acc, field, true) end)
end
end

View file

@ -0,0 +1,239 @@
defmodule MvWeb.MemberLive.Index.FieldVisibility do
@moduledoc """
Manages field visibility by merging user-specific selection with global settings.
This module handles:
- Getting all available fields (member fields + custom fields)
- Merging user selection with global settings (user selection takes priority)
- Falling back to global settings when no user selection exists
- Converting between different field name formats (atoms vs strings)
## Field Naming Convention
- **Member Fields**: Atoms (e.g., `:first_name`, `:email`)
- **Custom Fields**: Strings with format `"custom_field_<id>"` (e.g., `"custom_field_abc-123"`)
## Priority Order
1. User-specific selection (from URL/Session/Cookie)
2. Global settings (from database)
3. Default (all fields visible)
"""
@doc """
Gets all available fields for selection.
Returns a list of field identifiers:
- Member fields as atoms (e.g., `:first_name`, `:email`)
- Custom fields as strings (e.g., `"custom_field_abc-123"`)
## Parameters
- `custom_fields` - List of CustomField resources that are available
## Returns
List of field identifiers (atoms and strings)
"""
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
def get_all_available_fields(custom_fields) do
member_fields = Mv.Constants.member_fields()
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
member_fields ++ custom_field_names
end
@doc """
Merges user field selection with global settings.
User selection takes priority over global settings. If a field is not in the
user selection, the global setting is used. If a field is not in global settings,
it defaults to `true` (visible).
## Parameters
- `user_selection` - Map of field names (strings) to boolean visibility
- `global_settings` - Settings struct with `member_field_visibility` field
- `custom_fields` - List of CustomField resources
## Returns
Map of field names (strings) to boolean visibility values
## Examples
iex> user_selection = %{"first_name" => false}
iex> settings = %{member_field_visibility: %{first_name: true, email: true}}
iex> merge_with_global_settings(user_selection, settings, [])
%{"first_name" => false, "email" => true} # User selection overrides global
"""
@spec merge_with_global_settings(
%{String.t() => boolean()},
map(),
[struct()]
) :: %{String.t() => boolean()}
def merge_with_global_settings(user_selection, global_settings, custom_fields) do
all_fields = get_all_available_fields(custom_fields)
global_visibility = get_global_visibility_map(global_settings, custom_fields)
Enum.reduce(all_fields, %{}, fn field, acc ->
field_string = field_to_string(field)
visibility =
case Map.get(user_selection, field_string) do
nil -> Map.get(global_visibility, field_string, true)
user_value -> user_value
end
Map.put(acc, field_string, visibility)
end)
end
@doc """
Gets the list of visible fields from a field selection map.
Returns only fields where visibility is `true`.
## Parameters
- `field_selection` - Map of field names to boolean visibility
## Returns
List of field identifiers (atoms for member fields, strings for custom fields)
## Examples
iex> selection = %{"first_name" => true, "email" => false, "street" => true}
iex> get_visible_fields(selection)
[:first_name, :street]
"""
@spec get_visible_fields(%{String.t() => boolean()}) :: [atom() | String.t()]
def get_visible_fields(field_selection) when is_map(field_selection) do
field_selection
|> Enum.filter(fn {_field, visible} -> visible end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
end
def get_visible_fields(_), do: []
@doc """
Gets visible member fields from field selection.
Returns only member fields (atoms) that are visible.
## Examples
iex> selection = %{"first_name" => true, "email" => true, "custom_field_123" => true}
iex> get_visible_member_fields(selection)
[:first_name, :email]
"""
@spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields(field_selection) when is_map(field_selection) do
member_fields = Mv.Constants.member_fields()
field_selection
|> Enum.filter(fn {field_string, visible} ->
field_atom = to_field_identifier(field_string)
visible && field_atom in member_fields
end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
end
def get_visible_member_fields(_), do: []
@doc """
Gets visible custom fields from field selection.
Returns only custom field identifiers (strings) that are visible.
## Examples
iex> selection = %{"first_name" => true, "custom_field_123" => true, "custom_field_456" => false}
iex> get_visible_custom_fields(selection)
["custom_field_123"]
"""
@spec get_visible_custom_fields(%{String.t() => boolean()}) :: [String.t()]
def get_visible_custom_fields(field_selection) when is_map(field_selection) do
prefix = Mv.Constants.custom_field_prefix()
field_selection
|> Enum.filter(fn {field_string, visible} ->
visible && String.starts_with?(field_string, prefix)
end)
|> Enum.map(fn {field_string, _visible} -> field_string end)
end
def get_visible_custom_fields(_), do: []
# Gets global visibility map from settings
defp get_global_visibility_map(settings, custom_fields) do
member_visibility = get_member_field_visibility_from_settings(settings)
custom_field_visibility = get_custom_field_visibility(custom_fields)
Map.merge(member_visibility, custom_field_visibility)
end
# Gets member field visibility from settings
defp get_member_field_visibility_from_settings(settings) do
visibility_config =
normalize_visibility_config(Map.get(settings, :member_field_visibility, %{}))
member_fields = Mv.Constants.member_fields()
Enum.reduce(member_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field)
show_in_overview = Map.get(visibility_config, field, true)
Map.put(acc, field_string, show_in_overview)
end)
end
# Gets custom field visibility (all custom fields with show_in_overview=true are visible)
defp get_custom_field_visibility(custom_fields) do
prefix = Mv.Constants.custom_field_prefix()
Enum.reduce(custom_fields, %{}, fn custom_field, acc ->
field_string = "#{prefix}#{custom_field.id}"
visible = Map.get(custom_field, :show_in_overview, true)
Map.put(acc, field_string, visible)
end)
end
# Normalizes visibility config map keys from strings to atoms
defp normalize_visibility_config(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError -> acc
end
_, acc ->
acc
end)
end
defp normalize_visibility_config(_), do: %{}
# Converts field string to atom (for member fields) or keeps as string (for custom fields)
defp to_field_identifier(field_string) when is_binary(field_string) do
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
field_string
else
try do
String.to_existing_atom(field_string)
rescue
ArgumentError -> field_string
end
end
end
# Converts field identifier 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
end

View file

@ -6,6 +6,7 @@ defmodule MvWeb.MemberLive.Index.Formatter do
formats them appropriately for display in the UI.
"""
use Gettext, backend: MvWeb.Gettext
alias MvWeb.Helpers.DateFormatter
@doc """
Formats a custom field value for display.
@ -61,11 +62,11 @@ defmodule MvWeb.MemberLive.Index.Formatter do
defp format_value_by_type(value, :boolean, _) when value == false, do: gettext("No")
defp format_value_by_type(value, :boolean, _), do: to_string(value)
defp format_value_by_type(%Date{} = date, :date, _), do: Date.to_string(date)
defp format_value_by_type(%Date{} = date, :date, _), do: DateFormatter.format_date(date)
defp format_value_by_type(value, :date, _) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date} -> Date.to_string(date)
{:ok, date} -> DateFormatter.format_date(date)
_ -> value
end
end

View file

@ -3,19 +3,16 @@ defmodule MvWeb.MemberLive.Show do
LiveView for displaying a single member's details.
## Features
- Display all member information (personal, contact, address)
- Show linked user account (if exists)
- Display custom field values
- Display all member information in grouped sections
- Tab navigation for future features (Payments)
- Show custom field values with type-based formatting
- Navigate to edit form
- Return to member list
## Displayed Information
- Basic: name, email, dates (birth, join, exit)
- Contact: phone number
- Address: street, house number, postal code, city
- Status: paid flag
- Relationships: linked user account
- Custom: dynamic custom field values from CustomFields
## Sections
- Personal Data: Name, address, contact information, membership dates, notes
- Custom Fields: Dynamic fields in uniform grid layout (sorted by name)
- Payment Data: Mockup section with placeholder data
## Navigation
- Back to member list
@ -28,67 +25,150 @@ defmodule MvWeb.MemberLive.Show do
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@member.first_name} {@member.last_name}
<:subtitle>{gettext("This is a member record from your database.")}</:subtitle>
<%!-- Header with Back button, Name, and Edit button --%>
<div class="flex items-center justify-between gap-4 pb-4">
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<:actions>
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
<.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to members list")}</span>
</.button>
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> {gettext("Edit Member")}
</.button>
</:actions>
</.header>
<h1 class="text-2xl font-bold text-center flex-1">
{@member.first_name} {@member.last_name}
</h1>
<.list>
<:item title={gettext("Id")}>{@member.id}</:item>
<:item title={gettext("First Name")}>{@member.first_name}</:item>
<:item title={gettext("Last Name")}>{@member.last_name}</:item>
<:item title={gettext("Email")}>{@member.email}</:item>
<:item title={gettext("Birth Date")}>{@member.birth_date}</:item>
<:item title={gettext("Paid")}>
{if @member.paid, do: gettext("Yes"), else: gettext("No")}
</:item>
<:item title={gettext("Phone Number")}>{@member.phone_number}</:item>
<:item title={gettext("Join Date")}>{@member.join_date}</:item>
<:item title={gettext("Exit Date")}>{@member.exit_date}</:item>
<:item title={gettext("Notes")}>{@member.notes}</:item>
<:item title={gettext("City")}>{@member.city}</:item>
<:item title={gettext("Street")}>{@member.street}</:item>
<:item title={gettext("House Number")}>{@member.house_number}</:item>
<:item title={gettext("Postal Code")}>{@member.postal_code}</:item>
<:item title={gettext("Linked User")}>
<%= if @member.user do %>
<.link
navigate={~p"/users/#{@member.user}"}
class="text-blue-600 hover:text-blue-800 underline"
>
<.icon name="hero-user" class="h-4 w-4 inline mr-1" />
{@member.user.email}
</.link>
<% else %>
<span class="text-gray-500 italic">{gettext("No user linked")}</span>
<% end %>
</:item>
</.list>
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
{gettext("Edit Member")}
</.button>
</div>
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
<.generic_list items={
Enum.map(@member.custom_field_values, fn cfv ->
{
# name
cfv.custom_field && cfv.custom_field.name,
# value
case cfv.value do
%{value: v} -> v
v -> v
end
}
end)
} />
<%!-- Tab Navigation --%>
<div role="tablist" class="tabs tabs-bordered mb-6">
<button role="tab" class="tab tab-active" aria-selected="true">
<.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")}>
<.icon name="hero-credit-card" class="size-4 mr-2" />
{gettext("Payments")}
</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>
<%!-- 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 %>
<div>
<.data_field label={gettext("Notes")}>
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
</.data_field>
</div>
<% end %>
</div>
</.section_box>
</div>
<%!-- Custom Fields Section --%>
<%= if Enum.any?(@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)}
</.data_field>
<% 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>
<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>
</Layouts.app>
"""
end
@ -115,4 +195,120 @@ defmodule MvWeb.MemberLive.Show do
defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit Member")
# -----------------------------------------------------------------
# Helper Components
# -----------------------------------------------------------------
# Renders a section box with border and title.
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
# Renders a labeled data field.
attr :label, :string, required: true
attr :value, :string, default: nil
attr :class, :string, default: ""
slot :inner_block
defp data_field(assigns) do
~H"""
<dl class={@class}>
<dt class="text-sm font-medium text-base-content/70">{@label}</dt>
<dd class="mt-1 text-base-content">
<%= if @inner_block != [] do %>
{render_slot(@inner_block)}
<% else %>
{display_value(@value)}
<% end %>
</dd>
</dl>
"""
end
# -----------------------------------------------------------------
# Helper Functions
# -----------------------------------------------------------------
defp display_value(nil), do: ""
defp display_value(""), do: ""
defp display_value(value), do: value
defp format_address(member) do
street_part =
[member.street, member.house_number]
|> Enum.filter(&(&1 && &1 != ""))
|> Enum.join(" ")
city_part =
[member.postal_code, member.city]
|> Enum.filter(&(&1 && &1 != ""))
|> Enum.join(" ")
[street_part, city_part]
|> Enum.filter(&(&1 != ""))
|> Enum.join(", ")
|> case do
"" -> nil
address -> address
end
end
defp format_date(nil), do: nil
defp format_date(%Date{} = date) do
Calendar.strftime(date, "%d.%m.%Y")
end
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) || ""
end)
end
# Formats custom field value based on type
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
defp format_custom_field_value(%Date{} = date, :date) do
Calendar.strftime(date, "%d.%m.%Y")
end
defp format_custom_field_value(value, :email) when is_binary(value) do
assigns = %{email: value}
~H"""
<a href={"mailto:#{@email}"} class="text-blue-700 hover:text-blue-800 underline">{@email}</a>
"""
end
defp format_custom_field_value(value, :integer) when is_integer(value) do
Integer.to_string(value)
end
defp format_custom_field_value(value, _type) when is_binary(value) do
if String.trim(value) == "", do: "", else: value
end
defp format_custom_field_value(value, _type), do: to_string(value)
end

View file

@ -42,7 +42,7 @@ defmodule MvWeb.UserLive.Form do
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
</.header>
<.form for={@form} id="user-form" phx-change="validate" phx-submit="save">
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
<!-- Password Section -->
@ -61,7 +61,7 @@ defmodule MvWeb.UserLive.Form do
</label>
<%= if @show_password_fields do %>
<div class="mt-4 space-y-4 p-4 bg-gray-50 rounded-lg">
<div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
<.input
field={@form[:password]}
label={gettext("Password")}
@ -83,7 +83,7 @@ defmodule MvWeb.UserLive.Form do
<div class="text-sm text-gray-600">
<p><strong>{gettext("Password requirements")}:</strong></p>
<ul class="list-disc list-inside text-xs mt-1 space-y-1">
<ul class="mt-1 space-y-1 text-xs list-disc list-inside">
<li>{gettext("At least 8 characters")}</li>
<li>{gettext("Include both letters and numbers")}</li>
<li>{gettext("Consider using special characters")}</li>
@ -91,7 +91,7 @@ defmodule MvWeb.UserLive.Form do
</div>
<%= if @user do %>
<div class="mt-3 p-3 bg-orange-50 border border-orange-200 rounded">
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
<p class="text-sm text-orange-800">
<strong>{gettext("Admin Note")}:</strong> {gettext(
"As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
@ -102,7 +102,7 @@ defmodule MvWeb.UserLive.Form do
</div>
<% else %>
<%= if @user do %>
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
<div class="p-4 mt-4 rounded-lg bg-blue-50">
<p class="text-sm text-blue-800">
<strong>{gettext("Note")}:</strong> {gettext(
"Check 'Change Password' above to set a new password for this user."
@ -110,7 +110,7 @@ defmodule MvWeb.UserLive.Form do
</p>
</div>
<% else %>
<div class="mt-4 p-4 bg-yellow-50 rounded-lg">
<div class="p-4 mt-4 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"User will be created without a password. Check 'Set Password' to add one."
@ -123,11 +123,11 @@ defmodule MvWeb.UserLive.Form do
<!-- Member Linking Section -->
<div class="mt-6">
<h2 class="text-base font-semibold mb-3">{gettext("Linked Member")}</h2>
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
<%= if @user && @user.member && !@unlink_member do %>
<!-- Show linked member with unlink button -->
<div class="p-4 bg-green-50 border border-green-200 rounded-lg">
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-green-900">
@ -147,7 +147,7 @@ defmodule MvWeb.UserLive.Form do
<% else %>
<%= if @unlink_member do %>
<!-- Show unlink pending message -->
<div class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
"Member will be unlinked when you save. Cannot select new member until saved."
@ -219,7 +219,7 @@ defmodule MvWeb.UserLive.Form do
</div>
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
<div class="p-3 bg-yellow-50 border border-yellow-200 rounded">
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
@ -231,12 +231,12 @@ defmodule MvWeb.UserLive.Form do
<%= if @selected_member_id && @selected_member_name do %>
<div
id="member-selected"
class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg"
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
>
<p class="text-sm text-blue-800">
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
</p>
<p class="text-xs text-blue-600 mt-1">
<p class="mt-1 text-xs text-blue-600">
{gettext("Save to confirm linking.")}
</p>
</div>
@ -245,10 +245,12 @@ defmodule MvWeb.UserLive.Form do
<% end %>
</div>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save User")}
</.button>
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
<div class="mt-4">
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save User")}
</.button>
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
</div>
</.form>
</Layouts.app>
"""

View file

@ -49,7 +49,6 @@
>
{user.email}
</:col>
<:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id}</:col>
<:col :let={user} label={gettext("Linked Member")}>
<%= if user.member do %>
{user.member.first_name} {user.member.last_name}

View file

@ -46,9 +46,7 @@ defmodule MvWeb.UserLive.Show do
</.header>
<.list>
<:item title={gettext("ID")}>{@user.id}</:item>
<:item title={gettext("Email")}>{@user.email}</:item>
<:item title={gettext("OIDC ID")}>{@user.oidc_id || gettext("Not set")}</:item>
<:item title={gettext("Password Authentication")}>
{if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
</:item>
@ -56,13 +54,13 @@ defmodule MvWeb.UserLive.Show do
<%= if @user.member do %>
<.link
navigate={~p"/members/#{@user.member}"}
class="text-blue-600 hover:text-blue-800 underline"
class="text-blue-600 underline hover:text-blue-800"
>
<.icon name="hero-users" class="h-4 w-4 inline mr-1" />
<.icon name="hero-users" class="inline w-4 h-4 mr-1" />
{@user.member.first_name} {@user.member.last_name}
</.link>
<% else %>
<span class="text-gray-500 italic">{gettext("No member linked")}</span>
<span class="italic text-gray-500">{gettext("No member linked")}</span>
<% end %>
</:item>
</.list>

View file

@ -55,12 +55,6 @@ defmodule MvWeb.Router do
live "/members/:id", MemberLive.Show, :show
live "/members/:id/show/edit", MemberLive.Show, :edit
live "/custom_fields", CustomFieldLive.Index, :index
live "/custom_fields/new", CustomFieldLive.Form, :new
live "/custom_fields/:id/edit", CustomFieldLive.Form, :edit
live "/custom_fields/:id", CustomFieldLive.Show, :show
live "/custom_fields/:id/show/edit", CustomFieldLive.Show, :edit
live "/custom_field_values", CustomFieldValueLive.Index, :index
live "/custom_field_values/new", CustomFieldValueLive.Form, :new
live "/custom_field_values/:id/edit", CustomFieldValueLive.Form, :edit
@ -75,6 +69,11 @@ defmodule MvWeb.Router do
live "/settings", GlobalSettingsLive
# Contribution Management (Mock-ups)
live "/contribution_types", ContributionTypeLive.Index, :index
live "/contribution_settings", ContributionSettingsLive
live "/contributions/member/:id", ContributionPeriodLive.Show, :show
post "/set_locale", LocaleController, :set_locale
end

View file

@ -12,7 +12,8 @@ defmodule Mv.MixProject do
compilers: [:phoenix_live_view] ++ Mix.compilers(),
aliases: aliases(),
deps: deps(),
listeners: [Phoenix.CodeReloader]
listeners: [Phoenix.CodeReloader],
gettext: [write_reference_line_numbers: false]
]
end

View file

@ -36,7 +36,7 @@ msgstr ""
msgid "Need an account?"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen
msgid "Password"
msgstr ""
@ -65,78 +65,77 @@ msgstr ""
msgid "Your password has successfully been reset"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:254
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:289
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:163
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Incorrect password. Please try again."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:37
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Invalid session. Please try again."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:281
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Link Account"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:252
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Link OIDC Account"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:280
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Linking..."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:40
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Session expired. Please try again."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:209
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:76
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Account activated! Redirecting to complete sign-in..."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to link account. Please try again or contact support."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:108
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:98
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "This OIDC account is already linked to another user. Please contact support."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:235
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Language selection"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:242
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr ""

View file

@ -35,7 +35,7 @@ msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit einer A
msgid "Need an account?"
msgstr "Konto anlegen?"
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen
msgid "Password"
msgstr "Passwort"
@ -64,78 +64,77 @@ msgstr "Anmelden..."
msgid "Your password has successfully been reset"
msgstr "Das Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/auth/link_oidc_account_live.ex:254
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
msgstr "Ein Konto mit der E-Mail %{email} existiert bereits. Bitte geben Sie Ihr Passwort ein, um Ihr OIDC-Konto zu verknüpfen."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:289
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr "Abbrechen"
#: lib/mv_web/live/auth/link_oidc_account_live.ex:163
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Incorrect password. Please try again."
msgstr "Falsches Passwort. Bitte versuchen Sie es erneut."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:37
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Invalid session. Please try again."
msgstr "Ungültige Sitzung. Bitte versuchen Sie es erneut."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:281
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Link Account"
msgstr "Konto verknüpfen"
#: lib/mv_web/live/auth/link_oidc_account_live.ex:252
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Link OIDC Account"
msgstr "OIDC-Konto verknüpfen"
#: lib/mv_web/live/auth/link_oidc_account_live.ex:280
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Linking..."
msgstr "Verknüpfen..."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:40
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Session expired. Please try again."
msgstr "Sitzung abgelaufen. Bitte versuchen Sie es erneut."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:209
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
msgstr "Ihr OIDC-Konto wurde erfolgreich verknüpft! Sie werden zur Anmeldung weitergeleitet..."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:76
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Account activated! Redirecting to complete sign-in..."
msgstr "Konto aktiviert! Sie werden zur Anmeldung weitergeleitet..."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to link account. Please try again or contact support."
msgstr "Verknüpfung des Kontos fehlgeschlagen. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:108
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
msgstr "Die E-Mail-Adresse aus Ihrem OIDC-Provider ist bereits für ein anderes Konto registriert. Bitte ändern Sie Ihre E-Mail-Adresse im Identity-Provider oder kontaktieren Sie den Support."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:98
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "This OIDC account is already linked to another user. Please contact support."
msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktieren Sie den Support."
#: lib/mv_web/live/auth/link_oidc_account_live.ex:235
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Language selection"
msgstr "Sprachauswahl"
#: lib/mv_web/live/auth/link_oidc_account_live.ex:242
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr "Sprache auswählen"

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -32,7 +32,7 @@ msgstr ""
msgid "Need an account?"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen
msgid "Password"
msgstr ""
@ -61,78 +61,77 @@ msgstr ""
msgid "Your password has successfully been reset"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:254
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:289
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:163
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Incorrect password. Please try again."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:37
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Invalid session. Please try again."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:281
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Link Account"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:252
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Link OIDC Account"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:280
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Linking..."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:40
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Session expired. Please try again."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:209
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:76
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Account activated! Redirecting to complete sign-in..."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Failed to link account. Please try again or contact support."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:108
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:98
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "This OIDC account is already linked to another user. Please contact support."
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:235
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Language selection"
msgstr ""
#: lib/mv_web/live/auth/link_oidc_account_live.ex:242
#: lib/mv_web/live/auth/link_oidc_account_live.ex
#, elixir-autogen, elixir-format
msgid "Select language"
msgstr ""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,21 @@
defmodule Mv.Repo.Migrations.AddMemberFieldVisibilityToSettings 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(:settings) do
add :member_field_visibility, :map
end
end
def down do
alter table(:settings) do
remove :member_field_visibility
end
end
end

View file

@ -0,0 +1,69 @@
defmodule Mv.Repo.Migrations.RemoveBirthDateFromMembers do
@moduledoc """
Removes the birth_date column from the members table.
The birth_date field has been removed from the application because most users
don't record birthday data. Users who need this can use a custom field instead.
This migration also updates the search_vector trigger to remove birth_date.
"""
use Ecto.Migration
def up do
# Update the trigger function to remove birth_date from search_vector
execute("""
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
# Remove the birth_date column
alter table(:members) do
remove :birth_date
end
end
def down do
# Add the birth_date column back
alter table(:members) do
add :birth_date, :date
end
# Restore the trigger function with birth_date
execute("""
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.birth_date::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
end
end

View file

@ -112,7 +112,6 @@ for member_attrs <- [
first_name: "Hans",
last_name: "Müller",
email: "hans.mueller@example.de",
birth_date: ~D[1985-06-15],
join_date: ~D[2023-01-15],
paid: true,
phone_number: "+49301234567",
@ -125,7 +124,6 @@ for member_attrs <- [
first_name: "Greta",
last_name: "Schmidt",
email: "greta.schmidt@example.de",
birth_date: ~D[1990-03-22],
join_date: ~D[2023-02-01],
paid: false,
phone_number: "+49309876543",
@ -139,7 +137,6 @@ for member_attrs <- [
first_name: "Friedrich",
last_name: "Wagner",
email: "friedrich.wagner@example.de",
birth_date: ~D[1978-11-08],
join_date: ~D[2022-11-10],
paid: true,
phone_number: "+49301122334",
@ -151,7 +148,6 @@ for member_attrs <- [
first_name: "Marianne",
last_name: "Wagner",
email: "marianne.wagner@example.de",
birth_date: ~D[1978-11-08],
join_date: ~D[2022-11-10],
paid: true,
phone_number: "+49301122334",
@ -186,7 +182,6 @@ linked_members = [
first_name: "Maria",
last_name: "Weber",
email: "maria.weber@example.de",
birth_date: ~D[1992-07-14],
join_date: ~D[2023-03-15],
paid: true,
phone_number: "+49301357924",
@ -202,7 +197,6 @@ linked_members = [
first_name: "Thomas",
last_name: "Klein",
email: "thomas.klein@example.de",
birth_date: ~D[1988-12-03],
join_date: ~D[2023-04-01],
paid: false,
phone_number: "+49302468135",

View file

@ -0,0 +1,144 @@
{
"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": "immutable",
"type": "boolean"
},
{
"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": "D31160C95D3D32BA715D493DE2D2B8D6572E0EC68AE14B928D99975BC8A81542",
"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"
}

View file

@ -0,0 +1,79 @@
{
"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": "club_name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "member_field_visibility",
"type": "map"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "F2823210AA9E6476074A218375F64CD80E7F9E04EECC4E94D4C7FD31A773C016",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "settings"
}

View file

@ -0,0 +1,80 @@
defmodule Mv.Membership.MemberFieldVisibilityTest do
@moduledoc """
Tests for member field visibility configuration.
Tests cover:
- Member fields are visible by default (show_in_overview: true)
- Member fields can be hidden (show_in_overview: false)
- Checking if a specific field is visible
- Configuration is stored in Settings resource
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
describe "show_in_overview?/1" do
test "returns true for all member fields by default" do
# When no settings exist or member_field_visibility is not configured
# Test with fields from constants
member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field ->
assert Member.show_in_overview?(field) == true,
"Field #{field} should be visible by default"
end)
end
test "returns false for fields with show_in_overview: false in settings" do
# Get or create settings
{:ok, settings} = Mv.Membership.get_settings()
# Use a field that exists in member fields
member_fields = Mv.Constants.member_fields()
field_to_hide = List.first(member_fields)
field_to_show = List.last(member_fields)
# Update settings to hide a field (use string keys for JSONB)
{:ok, _updated_settings} =
Mv.Membership.update_settings(settings, %{
member_field_visibility: %{Atom.to_string(field_to_hide) => false}
})
# JSONB may convert atom keys to string keys, so we check via show_in_overview? instead
assert Member.show_in_overview?(field_to_hide) == false
assert Member.show_in_overview?(field_to_show) == true
end
test "returns true for non-configured fields (default)" do
# Get or create settings
{:ok, settings} = Mv.Membership.get_settings()
# Use fields that exist in member fields
member_fields = Mv.Constants.member_fields()
fields_to_hide = Enum.take(member_fields, 2)
fields_to_show = Enum.take(member_fields, -2)
# Update settings to hide some fields (use string keys for JSONB)
visibility_config =
Enum.reduce(fields_to_hide, %{}, fn field, acc ->
Map.put(acc, Atom.to_string(field), false)
end)
{:ok, _updated_settings} =
Mv.Membership.update_settings(settings, %{
member_field_visibility: visibility_config
})
# Hidden fields should be false
Enum.each(fields_to_hide, fn field ->
assert Member.show_in_overview?(field) == false,
"Field #{field} should be hidden"
end)
# Unconfigured fields should still be true (default)
Enum.each(fields_to_show, fn field ->
assert Member.show_in_overview?(field) == true,
"Field #{field} should be visible by default"
end)
end
end
end

View file

@ -6,7 +6,6 @@ defmodule Mv.Membership.MemberTest do
@valid_attrs %{
first_name: "John",
last_name: "Doe",
birth_date: ~D[1990-01-01],
paid: true,
email: "john@example.com",
phone_number: "+49123456789",
@ -43,12 +42,6 @@ defmodule Mv.Membership.MemberTest do
assert error_message(errors, :email) =~ "is not a valid email"
end
test "Birth date is optional but must not be in the future" do
attrs = Map.put(@valid_attrs, :birth_date, Date.utc_today() |> Date.add(1))
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
assert error_message(errors, :birth_date) =~ "cannot be in the future"
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")

View file

@ -0,0 +1,21 @@
defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
describe "field visibility dropdown in member view" do
test "renders and toggles visibility", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members")
# Renders Dropdown
assert has_element?(view, "[data-testid='dropdown-menu']")
# Opens Dropdown
view |> element("[data-testid='dropdown-button']") |> render_click()
assert has_element?(view, "#field-visibility-menu")
assert has_element?(view, "button[phx-click='select_item'][phx-value-item='email']")
assert has_element?(view, "button[phx-click='select_all']")
assert has_element?(view, "button[phx-click='select_none']")
end
end
end

View file

@ -0,0 +1,183 @@
defmodule MvWeb.Components.PaymentFilterComponentTest do
@moduledoc """
Unit tests for the PaymentFilterComponent.
Tests cover:
- Rendering in all 3 filter states (nil, :paid, :not_paid)
- Event emission when selecting options
- ARIA attributes for accessibility
- Dropdown open/close behavior
"""
# async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
describe "rendering" do
test "renders with no filter active (nil)", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Should show "All" text and no badge
assert has_element?(view, "#payment-filter")
refute has_element?(view, "#payment-filter .badge")
end
test "renders with paid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_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
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=not_paid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
end
end
describe "dropdown behavior" do
test "dropdown opens on button click", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Initially dropdown is closed
refute has_element?(view, "#payment-filter ul[role='menu']")
# Click to open
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Dropdown should be visible
assert has_element?(view, "#payment-filter ul[role='menu']")
end
test "dropdown closes after selecting an option", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
assert has_element?(view, "#payment-filter ul[role='menu']")
# Select an option - this should close the dropdown
view
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
# After selection, dropdown should be closed
# Note: The dropdown closes via assign, which is reflected in the next render
refute has_element?(view, "#payment-filter ul[role='menu']")
end
end
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")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "All" option
view
|> element("#payment-filter button[phx-value-filter='']")
|> render_click()
# URL should not contain paid_filter param - wait for patch
assert_patch(view)
end
test "selecting 'Paid' sets the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open 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()
# Wait for patch and check URL contains paid_filter=paid
path = assert_patch(view)
assert path =~ "paid_filter=paid"
end
test "selecting 'Not paid' sets the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Not paid" option
view
|> element("#payment-filter button[phx-value-filter='not_paid']")
|> render_click()
# Wait for patch and check URL contains paid_filter=not_paid
path = assert_patch(view)
assert path =~ "paid_filter=not_paid"
end
end
describe "accessibility" do
test "has correct ARIA attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Main button should have aria-haspopup and aria-expanded
assert html =~ ~s(aria-haspopup="true")
assert html =~ ~s(aria-expanded="false")
assert html =~ ~s(aria-label=)
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
# Check aria-expanded is now true
assert html =~ ~s(aria-expanded="true")
# Menu should have role="menu"
assert html =~ ~s(role="menu")
# Options should have role="menuitemradio"
assert html =~ ~s(role="menuitemradio")
end
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")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
# "Paid" option should have aria-checked="true"
# Check both possible orderings of attributes
assert html =~ "aria-checked=\"true\"" and html =~ "phx-value-filter=\"paid\""
end
end
end

View file

@ -150,35 +150,27 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
assert has_element?(view, "[data-testid='email'] .opacity-40")
end
test "icon distribution is correct for all fields", %{conn: conn} do
test "icon distribution shows exactly one active sort icon", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Test neutral state - all fields except first name (default) should show neutral icons
# Test neutral state - only one field should have active sort icon
{:ok, _view, html_neutral} = live(conn, "/members")
# Count neutral icons (should be 7 - one for each field)
neutral_count =
html_neutral |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
assert neutral_count == 7
# Count active icons (should be 1)
# Count active icons (should be exactly 1 - ascending for default sort field)
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = html_neutral |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
assert up_count == 1
assert down_count == 0
# Test ascending state - one field active, others neutral
{:ok, _view, html_asc} = live(conn, "/members?sort_field=first_name&sort_order=asc")
assert up_count == 1, "Expected exactly 1 ascending icon, got #{up_count}"
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
# Should have exactly 1 ascending icon and 7 neutral icons
up_count = html_asc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
neutral_count = html_asc |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
down_count = html_asc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
# Test descending state
{:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc")
assert up_count == 1
assert neutral_count == 7
assert down_count == 0
up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = html_desc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
assert up_count == 0, "Expected 0 ascending icons, got #{up_count}"
assert down_count == 1, "Expected exactly 1 descending icon, got #{down_count}"
end
end

View file

@ -1,6 +1,7 @@
defmodule MvWeb.CustomFieldLive.DeletionTest do
@moduledoc """
Tests for CustomFieldLive.Index deletion modal and slug confirmation.
Tests for CustomFieldLive.IndexComponent deletion modal and slug confirmation.
Tests the custom field management component embedded in the settings page.
Tests cover:
- Opening deletion confirmation modal
@ -39,11 +40,11 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
# Create custom field value
create_custom_field_value(member, custom_field, "test")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
{:ok, view, _html} = live(conn, ~p"/settings")
# Click delete button
# Click delete button - find the delete link within the component
view
|> element("a", "Delete")
|> element("#custom-fields-component a", "Delete")
|> render_click()
# Modal should be visible
@ -65,10 +66,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
create_custom_field_value(member1, custom_field, "test1")
create_custom_field_value(member2, custom_field, "test2")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
{:ok, view, _html} = live(conn, ~p"/settings")
view
|> element("a", "Delete")
|> element("#custom-fields-component a", "Delete")
|> render_click()
# Should show plural form
@ -78,10 +79,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
test "shows 0 members for custom field without values", %{conn: conn} do
{:ok, _custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/custom_fields")
{:ok, view, _html} = live(conn, ~p"/settings")
view
|> element("a", "Delete")
|> element("#custom-fields-component a", "Delete")
|> render_click()
# Should show 0 members
@ -93,15 +94,16 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
test "updates confirmation state when typing", %{conn: conn} do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/custom_fields")
{:ok, view, _html} = live(conn, ~p"/settings")
view
|> element("a", "Delete")
|> element("#custom-fields-component a", "Delete")
|> render_click()
# Type in slug input
# Type in slug input - use element to find the form with phx-target
view
|> render_change("update_slug_confirmation", %{"slug" => custom_field.slug})
|> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => custom_field.slug})
# Confirm button should be enabled now (no disabled attribute)
html = render(view)
@ -111,15 +113,16 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
test "delete button is disabled when slug doesn't match", %{conn: conn} do
{:ok, _custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/custom_fields")
{:ok, view, _html} = live(conn, ~p"/settings")
view
|> element("a", "Delete")
|> element("#custom-fields-component a", "Delete")
|> render_click()
# Type wrong slug
# Type wrong slug - use element to find the form with phx-target
view
|> render_change("update_slug_confirmation", %{"slug" => "wrong-slug"})
|> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => "wrong-slug"})
# Button should be disabled
html = render(view)
@ -133,20 +136,21 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, custom_field_value} = create_custom_field_value(member, custom_field, "test")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
{:ok, view, _html} = live(conn, ~p"/settings")
# Open modal
view
|> element("a", "Delete")
|> element("#custom-fields-component a", "Delete")
|> render_click()
# Enter correct slug
# Enter correct slug - use element to find the form with phx-target
view
|> render_change("update_slug_confirmation", %{"slug" => custom_field.slug})
|> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => custom_field.slug})
# Click confirm
view
|> element("button", "Delete Custom Field and All Values")
|> element("#delete-custom-field-modal button", "Delete Custom Field and All Values")
|> render_click()
# Should show success message
@ -162,27 +166,28 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
assert {:ok, _} = Ash.get(Member, member.id)
end
test "shows error when slug doesn't match", %{conn: conn} do
test "button remains disabled and custom field not deleted when slug doesn't match", %{
conn: conn
} do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/custom_fields")
{:ok, view, _html} = live(conn, ~p"/settings")
view
|> element("a", "Delete")
|> element("#custom-fields-component a", "Delete")
|> render_click()
# Enter wrong slug
# Enter wrong slug - use element to find the form with phx-target
view
|> render_change("update_slug_confirmation", %{"slug" => "wrong-slug"})
|> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => "wrong-slug"})
# Try to confirm (button should be disabled, but test the handler anyway)
view
|> render_click("confirm_delete", %{})
# Button should be disabled and we cannot click it
# The test verifies that the button is properly disabled in the UI
html = render(view)
assert html =~ ~r/disabled(?:=""|(?!\w))/
# Should show error message
assert render(view) =~ "Slug does not match"
# Custom field should still exist
# Custom field should still exist since deletion couldn't proceed
assert {:ok, _} = Ash.get(CustomField, custom_field.id)
end
end
@ -191,10 +196,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
test "closes modal without deleting", %{conn: conn} do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/custom_fields")
{:ok, view, _html} = live(conn, ~p"/settings")
view
|> element("a", "Delete")
|> element("#custom-fields-component a", "Delete")
|> render_click()
# Modal should be visible
@ -202,7 +207,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
# Click cancel
view
|> element("button", "Cancel")
|> element("#delete-custom-field-modal button", "Cancel")
|> render_click()
# Modal should be gone

View file

@ -0,0 +1,370 @@
defmodule MvWeb.MemberLive.Index.FieldSelectionTest do
@moduledoc """
Tests for FieldSelection module handling cookie/session/URL management.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.FieldSelection
describe "get_from_session/1" do
test "returns empty map when session is empty" do
assert FieldSelection.get_from_session(%{}) == %{}
end
test "returns empty map when session key is missing" do
session = %{"other_key" => "value"}
assert FieldSelection.get_from_session(session) == %{}
end
test "parses valid JSON from session" do
json = Jason.encode!(%{"first_name" => true, "email" => false})
session = %{"member_field_selection" => json}
result = FieldSelection.get_from_session(session)
assert result == %{"first_name" => true, "email" => false}
end
test "handles invalid JSON gracefully" do
session = %{"member_field_selection" => "invalid json{["}
result = FieldSelection.get_from_session(session)
assert result == %{}
end
test "converts non-boolean values to true" do
json = Jason.encode!(%{"first_name" => "true", "email" => 1, "street" => true})
session = %{"member_field_selection" => json}
result = FieldSelection.get_from_session(session)
# All values should be booleans, non-booleans default to true
assert result["first_name"] == true
assert result["email"] == true
assert result["street"] == true
end
test "handles nil session" do
assert FieldSelection.get_from_session(nil) == %{}
end
test "handles non-map session" do
assert FieldSelection.get_from_session("not a map") == %{}
end
end
describe "save_to_session/2" do
test "saves field selection to session as JSON" do
session = %{}
selection = %{"first_name" => true, "email" => false}
result = FieldSelection.save_to_session(session, selection)
assert Map.has_key?(result, "member_field_selection")
assert Jason.decode!(result["member_field_selection"]) == selection
end
test "overwrites existing selection" do
session = %{"member_field_selection" => Jason.encode!(%{"old" => true})}
selection = %{"new" => true}
result = FieldSelection.save_to_session(session, selection)
assert Jason.decode!(result["member_field_selection"]) == selection
end
test "handles empty selection" do
session = %{}
selection = %{}
result = FieldSelection.save_to_session(session, selection)
assert Jason.decode!(result["member_field_selection"]) == %{}
end
test "handles invalid selection gracefully" do
session = %{}
result = FieldSelection.save_to_session(session, "not a map")
assert result == session
end
end
describe "get_from_cookie/1" do
test "returns empty map when cookie header is missing" do
conn = %Plug.Conn{}
result = FieldSelection.get_from_cookie(conn)
assert result == %{}
end
test "returns empty map when cookie is empty string" do
conn = Plug.Conn.put_req_header(%Plug.Conn{}, "cookie", "")
result = FieldSelection.get_from_cookie(conn)
assert result == %{}
end
test "parses valid JSON from cookie" do
selection = %{"first_name" => true, "email" => false}
cookie_value = selection |> Jason.encode!() |> URI.encode()
cookie_header = "member_field_selection=#{cookie_value}"
conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
result = FieldSelection.get_from_cookie(conn)
assert result == selection
end
test "handles invalid JSON in cookie gracefully" do
cookie_value = URI.encode("invalid{[")
cookie_header = "member_field_selection=#{cookie_value}"
conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
result = FieldSelection.get_from_cookie(conn)
assert result == %{}
end
test "handles cookie with other values" do
selection = %{"street" => true}
cookie_value = selection |> Jason.encode!() |> URI.encode()
cookie_header = "other_cookie=value; member_field_selection=#{cookie_value}; another=test"
conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
result = FieldSelection.get_from_cookie(conn)
assert result == selection
end
end
describe "save_to_cookie/2" do
test "saves field selection to cookie" do
conn = %Plug.Conn{}
selection = %{"first_name" => true, "email" => false}
result = FieldSelection.save_to_cookie(conn, selection)
# Check that cookie is set
assert result.resp_cookies["member_field_selection"]
cookie = result.resp_cookies["member_field_selection"]
assert cookie[:max_age] == 365 * 24 * 60 * 60
assert cookie[:same_site] == "Lax"
assert cookie[:http_only] == true
end
test "handles invalid selection gracefully" do
conn = %Plug.Conn{}
result = FieldSelection.save_to_cookie(conn, "not a map")
assert result == conn
end
end
describe "parse_from_url/1" do
test "returns empty map when params is empty" do
assert FieldSelection.parse_from_url(%{}) == %{}
end
test "returns empty map when fields parameter is missing" do
params = %{"query" => "test", "sort_field" => "first_name"}
assert FieldSelection.parse_from_url(params) == %{}
end
test "parses comma-separated field names" do
params = %{"fields" => "first_name,email,street"}
result = FieldSelection.parse_from_url(params)
assert result == %{
"first_name" => true,
"email" => true,
"street" => true
}
end
test "handles custom field names" do
params = %{"fields" => "custom_field_abc-123,custom_field_def-456"}
result = FieldSelection.parse_from_url(params)
assert result == %{
"custom_field_abc-123" => true,
"custom_field_def-456" => true
}
end
test "handles mixed member and custom fields" do
params = %{"fields" => "first_name,custom_field_123,email"}
result = FieldSelection.parse_from_url(params)
assert result == %{
"first_name" => true,
"custom_field_123" => true,
"email" => true
}
end
test "trims whitespace from field names" do
params = %{"fields" => " first_name , email , street "}
result = FieldSelection.parse_from_url(params)
assert result == %{
"first_name" => true,
"email" => true,
"street" => true
}
end
test "handles empty fields string" do
params = %{"fields" => ""}
assert FieldSelection.parse_from_url(params) == %{}
end
test "handles nil fields parameter" do
params = %{"fields" => nil}
assert FieldSelection.parse_from_url(params) == %{}
end
test "filters out empty field names" do
params = %{"fields" => "first_name,,email,"}
result = FieldSelection.parse_from_url(params)
assert result == %{
"first_name" => true,
"email" => true
}
end
test "handles non-map params" do
assert FieldSelection.parse_from_url(nil) == %{}
assert FieldSelection.parse_from_url("not a map") == %{}
end
end
describe "merge_sources/3" do
test "merges all sources with URL having highest priority" do
url_selection = %{"first_name" => false}
session_selection = %{"first_name" => true, "email" => true}
cookie_selection = %{"first_name" => true, "street" => true}
result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
# URL overrides session, session overrides cookie
assert result["first_name"] == false
assert result["email"] == true
assert result["street"] == true
end
test "handles empty sources" do
result = FieldSelection.merge_sources(%{}, %{}, %{})
assert result == %{}
end
test "cookie only" do
cookie_selection = %{"first_name" => true}
result = FieldSelection.merge_sources(%{}, %{}, cookie_selection)
assert result == %{"first_name" => true}
end
test "session overrides cookie" do
session_selection = %{"first_name" => false}
cookie_selection = %{"first_name" => true}
result = FieldSelection.merge_sources(%{}, session_selection, cookie_selection)
assert result["first_name"] == false
end
test "URL overrides everything" do
url_selection = %{"first_name" => true}
session_selection = %{"first_name" => false}
cookie_selection = %{"first_name" => false}
result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
assert result["first_name"] == true
end
test "combines fields from all sources" do
url_selection = %{"url_field" => true}
session_selection = %{"session_field" => true}
cookie_selection = %{"cookie_field" => true}
result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
assert result["url_field"] == true
assert result["session_field"] == true
assert result["cookie_field"] == true
end
end
describe "to_url_param/1" do
test "converts selection to comma-separated string" do
selection = %{"first_name" => true, "email" => true, "street" => false}
result = FieldSelection.to_url_param(selection)
# Only visible fields should be included (order may vary)
fields = String.split(result, ",") |> Enum.sort()
assert fields == ["email", "first_name"]
end
test "handles empty selection" do
assert FieldSelection.to_url_param(%{}) == ""
end
test "handles all fields hidden" do
selection = %{"first_name" => false, "email" => false}
result = FieldSelection.to_url_param(selection)
assert result == ""
end
test "preserves field order" do
selection = %{
"z_field" => true,
"a_field" => true,
"m_field" => true
}
result = FieldSelection.to_url_param(selection)
# Order should be preserved (map iteration order)
assert String.contains?(result, "z_field")
assert String.contains?(result, "a_field")
assert String.contains?(result, "m_field")
end
test "handles custom fields" do
selection = %{
"first_name" => true,
"custom_field_abc-123" => true,
"email" => false
}
result = FieldSelection.to_url_param(selection)
assert String.contains?(result, "first_name")
assert String.contains?(result, "custom_field_abc-123")
refute String.contains?(result, "email")
end
test "handles invalid input" do
assert FieldSelection.to_url_param(nil) == ""
assert FieldSelection.to_url_param("not a map") == ""
end
end
end

View file

@ -0,0 +1,336 @@
defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
@moduledoc """
Tests for FieldVisibility module handling field visibility merging logic.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.FieldVisibility
# Mock custom field structs for testing
defp create_custom_field(id, name, show_in_overview \\ true) do
%{
id: id,
name: name,
show_in_overview: show_in_overview
}
end
describe "get_all_available_fields/1" do
test "returns member fields and custom fields" do
custom_fields = [
create_custom_field("cf1", "Custom Field 1"),
create_custom_field("cf2", "Custom Field 2")
]
result = FieldVisibility.get_all_available_fields(custom_fields)
# Should include all member fields
assert :first_name in result
assert :email in result
assert :street in result
# Should include custom fields as strings
assert "custom_field_cf1" in result
assert "custom_field_cf2" in result
end
test "handles empty custom fields list" do
result = FieldVisibility.get_all_available_fields([])
# Should only have member fields
assert :first_name in result
assert :email in result
refute Enum.any?(result, fn field ->
is_binary(field) and String.starts_with?(field, "custom_field_")
end)
end
test "includes all member fields from constants" do
custom_fields = []
result = FieldVisibility.get_all_available_fields(custom_fields)
member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field ->
assert field in result
end)
end
end
describe "merge_with_global_settings/3" do
test "user selection overrides global settings" do
user_selection = %{"first_name" => false}
settings = %{member_field_visibility: %{first_name: true, email: true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "falls back to global settings when user selection is empty" do
user_selection = %{}
settings = %{member_field_visibility: %{first_name: false, email: true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "defaults to true when field not in settings" do
user_selection = %{}
settings = %{member_field_visibility: %{first_name: false}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# first_name from settings
assert result["first_name"] == false
# email defaults to true (not in settings)
assert result["email"] == true
end
test "handles custom fields visibility" do
user_selection = %{}
settings = %{member_field_visibility: %{}}
custom_fields = [
create_custom_field("cf1", "Custom 1", true),
create_custom_field("cf2", "Custom 2", false)
]
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["custom_field_cf1"] == true
assert result["custom_field_cf2"] == false
end
test "user selection overrides custom field visibility" do
user_selection = %{"custom_field_cf1" => false}
settings = %{member_field_visibility: %{}}
custom_fields = [
create_custom_field("cf1", "Custom 1", true)
]
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["custom_field_cf1"] == false
end
test "handles string keys in settings (JSONB format)" do
user_selection = %{}
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "handles mixed atom and string keys in settings" do
user_selection = %{}
# Use string keys only (as JSONB would return)
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "handles nil settings gracefully" do
user_selection = %{}
settings = %{member_field_visibility: nil}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# Should default all fields to true
assert result["first_name"] == true
assert result["email"] == true
end
test "handles missing member_field_visibility key" do
user_selection = %{}
settings = %{}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# Should default all fields to true
assert result["first_name"] == true
assert result["email"] == true
end
test "includes all fields in result" do
user_selection = %{"first_name" => false}
settings = %{member_field_visibility: %{email: true}}
custom_fields = [
create_custom_field("cf1", "Custom 1", true)
]
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# Should include all member fields
member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field ->
assert Map.has_key?(result, Atom.to_string(field))
end)
# Should include custom fields
assert Map.has_key?(result, "custom_field_cf1")
end
end
describe "get_visible_fields/1" do
test "returns only fields with true visibility" do
selection = %{
"first_name" => true,
"email" => false,
"street" => true,
"custom_field_123" => false
}
result = FieldVisibility.get_visible_fields(selection)
assert :first_name in result
assert :street in result
refute :email in result
refute "custom_field_123" in result
end
test "converts member field strings to atoms" do
selection = %{"first_name" => true, "email" => true}
result = FieldVisibility.get_visible_fields(selection)
assert :first_name in result
assert :email in result
end
test "keeps custom fields as strings" do
selection = %{"custom_field_abc-123" => true}
result = FieldVisibility.get_visible_fields(selection)
assert "custom_field_abc-123" in result
end
test "handles empty selection" do
assert FieldVisibility.get_visible_fields(%{}) == []
end
test "handles all fields hidden" do
selection = %{"first_name" => false, "email" => false}
assert FieldVisibility.get_visible_fields(selection) == []
end
test "handles invalid input" do
assert FieldVisibility.get_visible_fields(nil) == []
end
end
describe "get_visible_member_fields/1" do
test "returns only member fields that are visible" do
selection = %{
"first_name" => true,
"email" => true,
"custom_field_123" => true,
"street" => false
}
result = FieldVisibility.get_visible_member_fields(selection)
assert :first_name in result
assert :email in result
refute :street in result
refute "custom_field_123" in result
end
test "filters out custom fields" do
selection = %{
"first_name" => true,
"custom_field_123" => true,
"custom_field_456" => true
}
result = FieldVisibility.get_visible_member_fields(selection)
assert :first_name in result
refute "custom_field_123" in result
refute "custom_field_456" in result
end
test "handles empty selection" do
assert FieldVisibility.get_visible_member_fields(%{}) == []
end
test "handles invalid input" do
assert FieldVisibility.get_visible_member_fields(nil) == []
end
end
describe "get_visible_custom_fields/1" do
test "returns only custom fields that are visible" do
selection = %{
"first_name" => true,
"custom_field_123" => true,
"custom_field_456" => false,
"email" => true
}
result = FieldVisibility.get_visible_custom_fields(selection)
assert "custom_field_123" in result
refute "custom_field_456" in result
refute :first_name in result
refute :email in result
end
test "filters out member fields" do
selection = %{
"first_name" => true,
"email" => true,
"custom_field_123" => true
}
result = FieldVisibility.get_visible_custom_fields(selection)
assert "custom_field_123" in result
refute :first_name in result
refute :email in result
end
test "handles empty selection" do
assert FieldVisibility.get_visible_custom_fields(%{}) == []
end
test "handles fields that look like custom fields but aren't" do
selection = %{
"custom_field_123" => true,
"custom_field_like_name" => true,
"not_custom_field" => true
}
result = FieldVisibility.get_visible_custom_fields(selection)
assert "custom_field_123" in result
assert "custom_field_like_name" in result
refute "not_custom_field" in result
end
test "handles invalid input" do
assert FieldVisibility.get_visible_custom_fields(nil) == []
end
end
end

View file

@ -90,8 +90,6 @@ defmodule MvWeb.ProfileNavigationTest do
# Verify we're on the correct profile page with OIDC specific information
{:ok, _profile_view, html} = live(conn, "/users/#{user.id}")
assert html =~ to_string(user.email)
# OIDC ID should be visible
assert html =~ "oidc_123"
# Password auth should be disabled for OIDC users
assert html =~ "Not enabled"
end
@ -150,8 +148,6 @@ defmodule MvWeb.ProfileNavigationTest do
"/members/new",
"/custom_field_values",
"/custom_field_values/new",
"/custom_fields",
"/custom_fields/new",
"/users",
"/users/new"
]

View file

@ -9,7 +9,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
- Custom field values are correctly formatted for different types
- Members without custom field values show empty cell or "-"
"""
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
@ -230,8 +231,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Date should be displayed in readable format
assert html =~ "1990" or html =~ "1990-05-15" or html =~ "15.05.1990"
# Date should be displayed in European format (dd.mm.yyyy)
assert html =~ "15.05.1990"
end
test "formats email custom field values correctly", %{conn: conn, member1: _member1} do
@ -241,7 +242,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
assert html =~ "alice.private@example.com"
end
test "shows empty cell or placeholder for members without custom field values", %{
test "shows empty cell for members without custom field values", %{
conn: conn,
member2: _member2,
field_show_string: field
@ -252,11 +253,14 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
# The custom field column should exist
assert html =~ field.name
# Member2 should have an empty cell for this field
# We check that member2's row exists but doesn't have the value
assert html =~ "Bob Brown"
# The value should not appear for member2 (only for member1)
# We check that the value appears somewhere (for member1) but member2 row should have "-"
# Member2 should exist in the table (first_name and last_name are in separate columns)
assert html =~ "Bob"
assert html =~ "Brown"
# The value from member1 should appear (phone number)
assert html =~ "+49123456789"
# Note: Member2 doesn't have this custom field value, so the cell is empty
# The implementation shows "" for missing values, which is the expected behavior
end
end

View file

@ -0,0 +1,452 @@
defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
@moduledoc """
Integration tests for field visibility dropdown functionality.
Tests cover:
- Field selection dropdown rendering
- Toggling field visibility
- URL parameter persistence
- Select all / deselect all
- Integration with member list display
- Custom fields visibility
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
# Create test members
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com",
street: "Main St",
city: "Berlin"
})
|> Ash.create()
{:ok, member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com",
street: "Second St",
city: "Hamburg"
})
|> Ash.create()
# Create custom field
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "membership_number",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
# Create custom field values
{:ok, _cfv1} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: custom_field.id,
value: "M001"
})
|> Ash.create()
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member2.id,
custom_field_id: custom_field.id,
value: "M002"
})
|> Ash.create()
%{
member1: member1,
member2: member2,
custom_field: custom_field
}
end
describe "field visibility dropdown" do
test "renders dropdown button", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ "Columns"
assert html =~ ~s(aria-controls="field-visibility-menu")
end
test "opens dropdown when button is clicked", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Initially closed
refute has_element?(view, "ul#field-visibility-menu")
# Click button
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Should be open now
assert has_element?(view, "ul#field-visibility-menu")
end
test "displays all member fields in dropdown", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
html = render(view)
# Check for member fields (formatted labels)
assert html =~ "First Name" or html =~ "first_name"
assert html =~ "Email" or html =~ "email"
assert html =~ "Street" or html =~ "street"
end
test "displays custom fields in dropdown", %{conn: conn, custom_field: custom_field} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
html = render(view)
assert html =~ custom_field.name
end
end
describe "field visibility toggling" do
test "hiding a field removes it from display", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Verify email is visible initially
html = render(view)
assert html =~ "alice@example.com"
# Open dropdown and hide email
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_click()
# Wait for update
:timer.sleep(100)
# Email should no longer be visible
html = render(view)
refute html =~ "alice@example.com"
refute html =~ "bob@example.com"
end
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Verify custom field is visible initially
html = render(view)
assert html =~ "M001" or html =~ custom_field.name
# Open dropdown and hide custom field
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
custom_field_id = custom_field.id
custom_field_string = "custom_field_#{custom_field_id}"
view
|> element("button[phx-click='select_item'][phx-value-item='#{custom_field_string}']")
|> render_click()
# Wait for update
:timer.sleep(100)
# Custom field should no longer be visible
html = render(view)
refute html =~ "M001"
refute html =~ "M002"
end
end
describe "select all / deselect all" do
test "select all makes all fields visible", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Start with some fields hidden
{:ok, view, _html} = live(conn, "/members?fields=first_name")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Click select all
view
|> element("button[phx-click='select_all']")
|> render_click()
# Wait for update
:timer.sleep(100)
# All fields should be visible
html = render(view)
assert html =~ "alice@example.com"
assert html =~ "Main St"
assert html =~ "Berlin"
end
test "deselect all hides all fields except first_name", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Click deselect all
view
|> element("button[phx-click='select_none']")
|> render_click()
# Wait for update
:timer.sleep(100)
# Only first_name should be visible (it's always shown)
html = render(view)
# Email and street should be hidden
refute html =~ "alice@example.com"
refute html =~ "Main St"
end
end
describe "URL parameter persistence" do
test "field selection is persisted in URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown and hide email
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_click()
# Wait for URL update
:timer.sleep(100)
# Check that URL contains fields parameter
# Note: In LiveView tests, we check the rendered HTML for the updated state
# The actual URL update happens via push_patch
end
test "loading page with fields parameter applies selection", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Load with first_name and city explicitly set in URL
# Note: Other fields may still be visible due to global settings
{:ok, view, _html} = live(conn, "/members?fields=first_name,city")
html = render(view)
# first_name and city should be visible
assert html =~ "Alice"
assert html =~ "Berlin"
# Note: email and street may still be visible if global settings allow it
# This test verifies that the URL parameters work, not that they hide other fields
end
test "fields parameter works with custom fields", %{conn: conn, custom_field: custom_field} do
conn = conn_with_oidc_user(conn)
custom_field_id = custom_field.id
# Load with custom field visible
{:ok, view, _html} =
live(conn, "/members?fields=first_name,custom_field_#{custom_field_id}")
html = render(view)
# Custom field should be visible
assert html =~ "M001" or html =~ custom_field.name
end
end
describe "integration with global settings" do
test "respects global settings when no user selection", %{conn: conn} do
# This test would require setting up global settings
# For now, we verify that the system works with default settings
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# All fields should be visible by default
assert html =~ "alice@example.com"
assert html =~ "Main St"
end
test "user selection overrides global settings", %{conn: conn} do
# This would require setting up global settings first
# Then verifying that user selection takes precedence
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Hide a field via dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_click()
:timer.sleep(100)
html = render(view)
refute html =~ "alice@example.com"
end
end
describe "edge cases" do
test "handles empty fields parameter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=")
# Should fall back to global settings
assert html =~ "alice@example.com"
end
test "handles invalid field names in URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
# Should ignore invalid fields and use defaults
assert html =~ "alice@example.com"
end
test "handles custom field that doesn't exist", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=first_name,custom_field_nonexistent")
# Should work without errors
assert html =~ "Alice"
end
test "handles rapid toggling", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Rapidly toggle a field multiple times
for _ <- 1..5 do
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_click()
:timer.sleep(50)
end
# Should still work correctly
html = render(view)
assert html =~ "Alice"
end
end
describe "accessibility" do
test "dropdown has proper ARIA attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ ~s(aria-controls="field-visibility-menu")
assert html =~ ~s(aria-haspopup="menu")
assert html =~ ~s(role="button")
end
test "menu items have proper ARIA attributes when open", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
html = render(view)
assert html =~ ~s(role="menu")
assert html =~ ~s(role="menuitemcheckbox")
assert html =~ ~s(aria-checked)
end
test "keyboard navigation works", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Check that elements are keyboard accessible
html = render(view)
assert html =~ ~s(tabindex="0")
# Check that keyboard events are supported
assert html =~ ~s(phx-keydown="select_item")
assert html =~ ~s(phx-key="Enter")
end
test "keyboard activation with Enter key works", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Verify email is visible initially
html = render(view)
assert html =~ "alice@example.com"
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Simulate Enter key press on email field button
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_keydown(%{key: "Enter"})
# Wait for update
:timer.sleep(100)
# Email should no longer be visible
html = render(view)
refute html =~ "alice@example.com"
end
end
end

View file

@ -0,0 +1,64 @@
defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.Member
setup do
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com",
street: "Main Street",
house_number: "123",
postal_code: "12345",
city: "Berlin",
phone_number: "+49123456789",
join_date: ~D[2020-01-15]
})
|> Ash.create()
{:ok, member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create()
%{
member1: member1,
member2: member2
}
end
test "shows multiple members correctly", %{conn: conn, member1: m1, member2: m2} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
assert html =~ field
end
end
test "respects show_in_overview config", %{conn: conn, member1: m} do
{:ok, settings} = Mv.Membership.get_settings()
fields_to_hide = [:street, :house_number]
{:ok, _} =
Mv.Membership.update_settings(settings, %{
member_field_visibility: Map.new(fields_to_hide, &{Atom.to_string(&1), false})
})
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ "Email"
assert html =~ m.email
refute html =~ m.street
end
end

View file

@ -249,4 +249,441 @@ defmodule MvWeb.MemberLive.IndexTest do
# Verify the member was actually deleted from the database
assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?())
end
describe "copy_emails feature" do
setup do
# Create test members
{:ok, member1} =
Mv.Membership.create_member(%{
first_name: "Max",
last_name: "Mustermann",
email: "max@example.com"
})
{:ok, member2} =
Mv.Membership.create_member(%{
first_name: "Erika",
last_name: "Musterfrau",
email: "erika@example.com"
})
{:ok, member3} =
Mv.Membership.create_member(%{
first_name: "Hans",
last_name: "Müller-Lüdenscheidt",
email: "hans@example.com"
})
%{member1: member1, member2: member2, member3: member3}
end
test "copy_emails event formats selected members correctly", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select two members
view
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|> render_click()
view
|> element("[phx-click='select_member'][phx-value-id='#{member2.id}']")
|> render_click()
# Trigger copy_emails event
view |> element("#copy-emails-btn") |> render_click()
# Verify flash message shows correct count
assert render(view) =~ "2"
end
test "copy_emails event with no selection shows error flash", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Trigger copy_emails event directly (button not visible when no selection)
# This tests the edge case where event is triggered without selection
result = render_hook(view, "copy_emails", %{})
# Should show error flash
assert result =~ "No members selected" or result =~ "Keine Mitglieder"
end
test "copy_emails event with all members selected formats all emails", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select all members via select_all
view |> element("[phx-click='select_all']") |> render_click()
# Trigger copy_emails event
view |> element("#copy-emails-btn") |> render_click()
# Verify flash message shows correct count (3 members)
assert render(view) =~ "3"
end
test "copy_emails handles members with special characters in names", %{
conn: conn,
member3: member3
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select member with umlauts
view
|> element("[phx-click='select_member'][phx-value-id='#{member3.id}']")
|> render_click()
# Trigger copy_emails event - should not crash
view |> element("#copy-emails-btn") |> render_click()
# Verify flash message shows success
assert render(view) =~ "1"
end
test "copy_emails handles case where selected member is deleted before copy", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select a member
view
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|> render_click()
# Delete the member from the database
Ash.destroy!(member1)
# Trigger copy_emails event directly - selection still contains the deleted ID
# but the member is no longer in @members list after reload
result = render_hook(view, "copy_emails", %{})
# Should show error since no visible members match selection
assert result =~ "No email" or result =~ "Keine E-Mail" or result =~ "0"
end
test "copy_emails formats emails as RFC 5322 compliant comma-separated list", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select two members
view
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|> render_click()
view
|> element("[phx-click='select_member'][phx-value-id='#{member2.id}']")
|> render_click()
# Get the socket state to verify the formatted email string
state = :sys.get_state(view.pid)
selected_members = state.socket.assigns.selected_members
# Verify MapSet is used
assert %MapSet{} = selected_members
assert MapSet.size(selected_members) == 2
end
test "email format is 'First Last <email>' with comma separator", %{
conn: conn,
member1: _member1
} do
# Test the format_member_email function indirectly
# by checking the push_event payload structure
conn = conn_with_oidc_user(conn)
# Create a member with known data
{:ok, test_member} =
Mv.Membership.create_member(%{
first_name: "Test",
last_name: "Format",
email: "test.format@example.com"
})
{:ok, view, _html} = live(conn, "/members")
# Select the test member
view
|> element("[phx-click='select_member'][phx-value-id='#{test_member.id}']")
|> render_click()
# The format should be "Test Format <test.format@example.com>"
# We verify this by checking the flash shows 1 email was copied
view |> element("#copy-emails-btn") |> render_click()
assert render(view) =~ "1"
end
test "copy button is not visible when no members are selected", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Ensure no members are selected (default state)
refute has_element?(view, "#copy-emails-btn")
end
test "copy button is visible when members are selected", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select a member
view
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|> render_click()
# Button should now be visible
assert has_element?(view, "#copy-emails-btn")
end
test "copy button click triggers event and shows flash", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select a member
view
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|> render_click()
# Click copy button
view |> element("#copy-emails-btn") |> render_click()
# Flash message should appear
assert has_element?(view, "#flash-group")
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
})
{:ok, unpaid_member} =
Mv.Membership.create_member(%{
first_name: "Nichtzahler",
last_name: "Mitglied",
email: "nichtzahler@example.com",
paid: false
})
{:ok, nil_paid_member} =
Mv.Membership.create_member(%{
first_name: "Unbestimmt",
last_name: "Mitglied",
email: "unbestimmt@example.com"
# paid is nil by default
})
%{paid_member: paid_member, unpaid_member: unpaid_member, nil_paid_member: nil_paid_member}
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
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
assert html =~ nil_paid_member.first_name
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
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=paid")
assert html =~ paid_member.first_name
refute html =~ unpaid_member.first_name
refute html =~ nil_paid_member.first_name
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
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?paid_filter=paid&sort_field=first_name&sort_order=asc")
# Click on email sort header
view
|> element("[data-testid='email']")
|> render_click()
# Filter should be preserved in URL
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"
end
end
end

View file

@ -33,8 +33,6 @@ defmodule MvWeb.UserLive.IndexTest do
assert html =~ "alice@example.com"
assert html =~ "bob@example.com"
assert html =~ "alice123"
assert html =~ "bob456"
end
test "shows correct action links", %{conn: conn} do
@ -386,10 +384,6 @@ defmodule MvWeb.UserLive.IndexTest do
# Should still show the table structure
assert html =~ "Email"
assert html =~ "OIDC ID"
# Should show the authenticated user at minimum
# Matches the generated email pattern oidc.user{unique_id}@example.com
assert html =~ "oidc.user"
end
test "handles users with missing OIDC ID", %{conn: conn} do

View file

@ -123,7 +123,13 @@ defmodule MvWeb.ConnCase do
end
setup tags do
Mv.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
pid = Mv.DataCase.setup_sandbox(tags)
conn = Phoenix.ConnTest.build_conn()
# Set metadata for Phoenix.Ecto.SQL.Sandbox plug to allow LiveView processes
# to share the test's database connection in async tests
conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid)
{:ok, conn: conn}
end
end

View file

@ -34,10 +34,12 @@ defmodule Mv.DataCase do
@doc """
Sets up the sandbox based on the test tags.
Returns the owner pid for use with Phoenix.Ecto.SQL.Sandbox.
"""
def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Mv.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
pid
end
@doc """