Compare commits
22 commits
d24096ceaf
...
2542bcf9e4
| Author | SHA1 | Date | |
|---|---|---|---|
| 2542bcf9e4 | |||
| ed961f7585 | |||
| 82f1a65b85 | |||
| dfff2486b5 | |||
| aaea6a01e2 | |||
| 4057b2d631 | |||
| cd1af5aff5 | |||
| 8391122426 | |||
| a5aeef3e27 | |||
| 422cf37a1e | |||
| a10d42f1ed | |||
| d1bab1288c | |||
| 1623b63207 | |||
| e6c5a58c65 | |||
| ee414c9440 | |||
| 366d4c104a | |||
| 09c75212b2 | |||
| ce15b8f59b | |||
| d8384098b4 | |||
| ee094eec2f | |||
| eedd24b93c | |||
| 06ba50f05d |
35 changed files with 4690 additions and 595 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -41,3 +41,6 @@ npm-debug.log
|
||||||
.env
|
.env
|
||||||
|
|
||||||
.elixir_ls/
|
.elixir_ls/
|
||||||
|
|
||||||
|
# Docker secrets directory (generated by `just init-secrets`)
|
||||||
|
/secrets/
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- CopyToClipboard JavaScript hook with fallback for older browsers
|
- CopyToClipboard JavaScript hook with fallback for older browsers
|
||||||
- Button shows count of visible selected members (respects search/filter)
|
- Button shows count of visible selected members (respects search/filter)
|
||||||
- German/English translations
|
- 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
|
### Fixed
|
||||||
- Email validation false positive when linking user and member with identical emails (#168 Problem #4)
|
- Email validation false positive when linking user and member with identical emails (#168 Problem #4)
|
||||||
|
|
|
||||||
29
Justfile
29
Justfile
|
|
@ -85,3 +85,32 @@ clean:
|
||||||
mix clean
|
mix clean
|
||||||
rm -rf .elixir_ls
|
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/^+//' {} \;
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
@ -217,6 +217,13 @@ For testing the production Docker build locally:
|
||||||
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
||||||
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
|
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
|
||||||
# OIDC_CLIENT_SECRET=<from-rauthy-client>
|
# OIDC_CLIENT_SECRET=<from-rauthy-client>
|
||||||
|
|
||||||
|
# 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):
|
3. **Start development environment** (for Rauthy):
|
||||||
|
|
@ -250,7 +257,7 @@ For actual production deployment:
|
||||||
- Set `OIDC_BASE_URL` to your production OIDC provider
|
- Set `OIDC_BASE_URL` to your production OIDC provider
|
||||||
- Configure proper Docker networks
|
- Configure proper Docker networks
|
||||||
3. **Set up SSL/TLS** (e.g., via reverse proxy like Nginx/Traefik)
|
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**
|
5. **Configure database backups**
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,75 @@ import Config
|
||||||
# any compile-time configuration in here, as it won't be applied.
|
# any compile-time configuration in here, as it won't be applied.
|
||||||
# The block below contains prod specific runtime configuration.
|
# 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
|
# ## Using releases
|
||||||
#
|
#
|
||||||
# If you use `mix release`, you need to explicitly enable the server
|
# If you use `mix release`, you need to explicitly enable the server
|
||||||
|
|
@ -21,12 +90,7 @@ if System.get_env("PHX_SERVER") do
|
||||||
end
|
end
|
||||||
|
|
||||||
if config_env() == :prod do
|
if config_env() == :prod do
|
||||||
database_url =
|
database_url = build_database_url.()
|
||||||
System.get_env("DATABASE_URL") ||
|
|
||||||
raise """
|
|
||||||
environment variable DATABASE_URL is missing.
|
|
||||||
For example: ecto://USER:PASS@HOST/DATABASE
|
|
||||||
"""
|
|
||||||
|
|
||||||
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
|
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
|
||||||
|
|
||||||
|
|
@ -41,12 +105,12 @@ if config_env() == :prod do
|
||||||
# want to use a different value for prod and you most likely don't want
|
# 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
|
# to check this value into version control, so we use an environment
|
||||||
# variable instead.
|
# variable instead.
|
||||||
|
# Supports SECRET_KEY_BASE or SECRET_KEY_BASE_FILE for Docker secrets.
|
||||||
secret_key_base =
|
secret_key_base =
|
||||||
System.get_env("SECRET_KEY_BASE") ||
|
get_env_or_file!.("SECRET_KEY_BASE", """
|
||||||
raise """
|
environment variable SECRET_KEY_BASE (or SECRET_KEY_BASE_FILE) is missing.
|
||||||
environment variable SECRET_KEY_BASE is missing.
|
You can generate one by calling: mix phx.gen.secret
|
||||||
You can generate one by calling: mix phx.gen.secret
|
""")
|
||||||
"""
|
|
||||||
|
|
||||||
host = System.get_env("PHX_HOST") || raise "Please define the PHX_HOST 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")
|
port = String.to_integer(System.get_env("PORT") || "4000")
|
||||||
|
|
@ -54,32 +118,47 @@ if config_env() == :prod do
|
||||||
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
||||||
|
|
||||||
# Rauthy OIDC configuration
|
# Rauthy OIDC configuration
|
||||||
|
# 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
|
||||||
|
|
||||||
config :mv, :rauthy,
|
config :mv, :rauthy,
|
||||||
client_id: System.get_env("OIDC_CLIENT_ID") || "mv",
|
client_id: oidc_client_id || "mv",
|
||||||
base_url: System.get_env("OIDC_BASE_URL") || "http://localhost:8080/auth/v1",
|
base_url: oidc_base_url || "http://localhost:8080/auth/v1",
|
||||||
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
|
client_secret: client_secret,
|
||||||
redirect_uri:
|
redirect_uri:
|
||||||
System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback"
|
System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback"
|
||||||
|
|
||||||
# Token signing secret from environment variable
|
# Token signing secret from environment variable
|
||||||
# This overrides the placeholder value set in prod.exs
|
# This overrides the placeholder value set in prod.exs
|
||||||
|
# Supports TOKEN_SIGNING_SECRET or TOKEN_SIGNING_SECRET_FILE for Docker secrets.
|
||||||
token_signing_secret =
|
token_signing_secret =
|
||||||
System.get_env("TOKEN_SIGNING_SECRET") ||
|
get_env_or_file!.("TOKEN_SIGNING_SECRET", """
|
||||||
raise """
|
environment variable TOKEN_SIGNING_SECRET (or TOKEN_SIGNING_SECRET_FILE) is missing.
|
||||||
environment variable TOKEN_SIGNING_SECRET is missing.
|
You can generate one by calling: mix phx.gen.secret
|
||||||
You can generate one by calling: mix phx.gen.secret
|
""")
|
||||||
"""
|
|
||||||
|
|
||||||
config :mv, :token_signing_secret, token_signing_secret
|
config :mv, :token_signing_secret, token_signing_secret
|
||||||
|
|
||||||
config :mv, MvWeb.Endpoint,
|
config :mv, MvWeb.Endpoint,
|
||||||
url: [host: host, port: 443, scheme: "https"],
|
url: [host: host, port: 443, scheme: "https"],
|
||||||
http: [
|
http: [
|
||||||
# Enable IPv6 and bind on all interfaces.
|
# Bind on all IPv4 interfaces.
|
||||||
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
# 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
|
# 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},
|
||||||
ip: {0, 0, 0, 0, 0, 0, 0, 0},
|
|
||||||
port: port
|
port: port
|
||||||
],
|
],
|
||||||
secret_key_base: secret_key_base,
|
secret_key_base: secret_key_base,
|
||||||
|
|
|
||||||
|
|
@ -45,3 +45,6 @@ config :mv, :token_signing_secret, "test_secret_key_for_ash_authentication_token
|
||||||
config :mv, :session_identifier, :unsafe
|
config :mv, :session_identifier, :unsafe
|
||||||
|
|
||||||
config :mv, :require_token_presence_for_authentication, false
|
config :mv, :require_token_presence_for_authentication, false
|
||||||
|
|
||||||
|
# Enable SQL Sandbox for async LiveView tests
|
||||||
|
config :mv, :sql_sandbox, true
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,32 @@ services:
|
||||||
app:
|
app:
|
||||||
image: git.local-it.org/local-it/mitgliederverwaltung:latest
|
image: git.local-it.org/local-it/mitgliederverwaltung:latest
|
||||||
container_name: mv-prod-app
|
container_name: mv-prod-app
|
||||||
# Use host network for local testing to access localhost:8080 (Rauthy)
|
ports:
|
||||||
# In real production, remove this and use external OIDC provider
|
- "4001:4001"
|
||||||
network_mode: host
|
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "ecto://postgres:postgres@localhost:5001/mv_prod"
|
# Database configuration using separate variables
|
||||||
SECRET_KEY_BASE: "${SECRET_KEY_BASE}"
|
# Use Docker service name for internal networking
|
||||||
TOKEN_SIGNING_SECRET: "${TOKEN_SIGNING_SECRET}"
|
DATABASE_HOST: "db-prod"
|
||||||
PHX_HOST: "${PHX_HOST}"
|
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"
|
PORT: "4001"
|
||||||
PHX_SERVER: "true"
|
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_CLIENT_ID: "mv"
|
||||||
OIDC_BASE_URL: "http://localhost:8080/auth/v1"
|
OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1"
|
||||||
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
|
OIDC_CLIENT_SECRET_FILE: "/run/secrets/oidc_client_secret"
|
||||||
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
|
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
|
||||||
|
secrets:
|
||||||
|
- db_password
|
||||||
|
- secret_key_base
|
||||||
|
- token_signing_secret
|
||||||
|
- oidc_client_secret
|
||||||
depends_on:
|
depends_on:
|
||||||
- db-prod
|
- db-prod
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
@ -26,13 +37,25 @@ services:
|
||||||
container_name: mv-prod-db
|
container_name: mv-prod-db
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
|
||||||
POSTGRES_DB: mv_prod
|
POSTGRES_DB: mv_prod
|
||||||
|
secrets:
|
||||||
|
- db_password
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data_prod:/var/lib/postgresql/data
|
- postgres_data_prod:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "5001:5432"
|
- "5001:5432"
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
postgres_data_prod:
|
postgres_data_prod:
|
||||||
|
|
|
||||||
653
docs/contributions-architecture.md
Normal file
653
docs/contributions-architecture.md
Normal 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**
|
||||||
|
|
||||||
527
docs/contributions-overview.md
Normal file
527
docs/contributions-overview.md
Normal 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
|
||||||
|
|
@ -187,10 +187,16 @@
|
||||||
|
|
||||||
**Current State:**
|
**Current State:**
|
||||||
- ✅ Basic "paid" boolean field on members
|
- ✅ Basic "paid" boolean field on members
|
||||||
|
- ✅ **UI Mock-ups for Contribution Types & Settings** (2025-12-02)
|
||||||
- ⚠️ No payment tracking
|
- ⚠️ No payment tracking
|
||||||
|
|
||||||
**Open Issues:**
|
**Open Issues:**
|
||||||
- [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority)
|
- [#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:**
|
**Missing Features:**
|
||||||
- ❌ Membership fee configuration
|
- ❌ Membership fee configuration
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
<p>{msg}</p>
|
<p>{msg}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1" />
|
<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" />
|
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -368,61 +368,63 @@ defmodule MvWeb.CoreComponents do
|
||||||
end
|
end
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<table class="table table-zebra">
|
<div class="overflow-auto">
|
||||||
<thead>
|
<table class="table table-zebra">
|
||||||
<tr>
|
<thead>
|
||||||
<th :for={col <- @col}>{col[:label]}</th>
|
<tr>
|
||||||
<th :for={dyn_col <- @dynamic_cols}>
|
<th :for={col <- @col}>{col[:label]}</th>
|
||||||
<.live_component
|
<th :for={dyn_col <- @dynamic_cols}>
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
<.live_component
|
||||||
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
field={"custom_field_#{dyn_col[:custom_field].id}"}
|
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
||||||
label={dyn_col[:custom_field].name}
|
field={"custom_field_#{dyn_col[:custom_field].id}"}
|
||||||
sort_field={@sort_field}
|
label={dyn_col[:custom_field].name}
|
||||||
sort_order={@sort_order}
|
sort_field={@sort_field}
|
||||||
/>
|
sort_order={@sort_order}
|
||||||
</th>
|
/>
|
||||||
<th :if={@action != []}>
|
</th>
|
||||||
<span class="sr-only">{gettext("Actions")}</span>
|
<th :if={@action != []}>
|
||||||
</th>
|
<span class="sr-only">{gettext("Actions")}</span>
|
||||||
</tr>
|
</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
</thead>
|
||||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||||
<td
|
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
||||||
:for={col <- @col}
|
<td
|
||||||
phx-click={@row_click && @row_click.(row)}
|
:for={col <- @col}
|
||||||
class={@row_click && "hover:cursor-pointer"}
|
phx-click={@row_click && @row_click.(row)}
|
||||||
>
|
class={["max-w-xs truncate", @row_click && "hover:cursor-pointer"]}
|
||||||
{render_slot(col, @row_item.(row))}
|
>
|
||||||
</td>
|
{render_slot(col, @row_item.(row))}
|
||||||
<td
|
</td>
|
||||||
:for={dyn_col <- @dynamic_cols}
|
<td
|
||||||
phx-click={@row_click && @row_click.(row)}
|
:for={dyn_col <- @dynamic_cols}
|
||||||
class={@row_click && "hover:cursor-pointer"}
|
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 dyn_col[:render] do
|
||||||
|
rendered = dyn_col[:render].(@row_item.(row))
|
||||||
|
|
||||||
if rendered == "" do
|
if rendered == "" do
|
||||||
""
|
""
|
||||||
|
else
|
||||||
|
rendered
|
||||||
|
end
|
||||||
else
|
else
|
||||||
rendered
|
""
|
||||||
end
|
end}
|
||||||
else
|
</td>
|
||||||
""
|
<td :if={@action != []} class="w-0 font-semibold">
|
||||||
end}
|
<div class="flex gap-4">
|
||||||
</td>
|
<%= for action <- @action do %>
|
||||||
<td :if={@action != []} class="w-0 font-semibold">
|
{render_slot(action, @row_item.(row))}
|
||||||
<div class="flex gap-4">
|
<% end %>
|
||||||
<%= for action <- @action do %>
|
</div>
|
||||||
{render_slot(action, @row_item.(row))}
|
</td>
|
||||||
<% end %>
|
</tr>
|
||||||
</div>
|
</tbody>
|
||||||
</td>
|
</table>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,17 @@ defmodule MvWeb.Layouts.Navbar do
|
||||||
<li><.link navigate="/members">{gettext("Members")}</.link></li>
|
<li><.link navigate="/members">{gettext("Members")}</.link></li>
|
||||||
<li><.link navigate="/custom_fields">{gettext("Custom Fields")}</.link></li>
|
<li><.link navigate="/custom_fields">{gettext("Custom Fields")}</.link></li>
|
||||||
<li><.link navigate="/users">{gettext("Users")}</.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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@ defmodule MvWeb.Endpoint do
|
||||||
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :mv
|
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :mv
|
||||||
end
|
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,
|
plug Phoenix.LiveDashboard.RequestLogger,
|
||||||
param_key: "request_logger",
|
param_key: "request_logger",
|
||||||
cookie_key: "request_logger"
|
cookie_key: "request_logger"
|
||||||
|
|
|
||||||
27
lib/mv_web/helpers/date_formatter.ex
Normal file
27
lib/mv_web/helpers/date_formatter.ex
Normal 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
|
||||||
|
|
@ -19,7 +19,7 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={aria_sort(@field, @sort_field, @sort_order)}
|
aria-label={aria_sort(@field, @sort_field, @sort_order)}
|
||||||
|
|
|
||||||
345
lib/mv_web/live/contribution_period_live/show.ex
Normal file
345
lib/mv_web/live/contribution_period_live/show.ex
Normal 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
|
||||||
277
lib/mv_web/live/contribution_settings_live.ex
Normal file
277
lib/mv_web/live/contribution_settings_live.ex
Normal 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
|
||||||
205
lib/mv_web/live/contribution_type_live/index.ex
Normal file
205
lib/mv_web/live/contribution_type_live/index.ex
Normal 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
|
||||||
|
|
@ -5,80 +5,212 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
## Features
|
## Features
|
||||||
- Create new members with personal information
|
- Create new members with personal information
|
||||||
- Edit existing member details
|
- 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
|
- Real-time validation with visual feedback
|
||||||
- Link/unlink user accounts
|
|
||||||
|
|
||||||
## Form Fields
|
## Form Sections
|
||||||
**Required:**
|
- Personal Data: Name, address, contact information, membership dates, notes
|
||||||
- first_name, last_name, email
|
- Custom Fields: Dynamic fields in uniform grid layout (displayed sorted by name)
|
||||||
|
- Payment Data: Mockup section (not editable)
|
||||||
**Optional:**
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
- `validate` - Real-time form validation
|
- `validate` - Real-time form validation
|
||||||
- `save` - Submit form (create or update member)
|
- `save` - Submit form (create or update member)
|
||||||
- Custom field value management events for adding/removing custom fields
|
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
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"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
|
||||||
{@page_title}
|
|
||||||
<:subtitle>
|
|
||||||
{gettext("Fields marked with an asterisk (*) cannot be empty.")}
|
|
||||||
</:subtitle>
|
|
||||||
</.header>
|
|
||||||
|
|
||||||
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
|
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
|
||||||
<.input field={@form[:first_name]} label={gettext("First Name")} required />
|
<%!-- Header with Back button, Name display, and Save button --%>
|
||||||
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
|
<div class="flex items-center justify-between gap-4 pb-4">
|
||||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
<.button navigate={return_path(@return_to, @member)} type="button">
|
||||||
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
<.input field={@form[:phone_number]} label={gettext("Phone Number")} />
|
{gettext("Back")}
|
||||||
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
|
</.button>
|
||||||
<.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")} />
|
|
||||||
|
|
||||||
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
|
<h1 class="text-2xl font-bold text-center flex-1">
|
||||||
<.inputs_for :let={f_custom_field_value} field={@form[:custom_field_values]}>
|
<%= if @member do %>
|
||||||
<% type =
|
{@member.first_name} {@member.last_name}
|
||||||
Enum.find(@custom_fields, &(&1.id == f_custom_field_value[:custom_field_id].value)) %>
|
<% else %>
|
||||||
<.inputs_for :let={value_form} field={f_custom_field_value[:value]}>
|
{gettext("New Member")}
|
||||||
<% input_type =
|
<% end %>
|
||||||
cond do
|
</h1>
|
||||||
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>
|
|
||||||
|
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
{gettext("Save Member")}
|
{gettext("Save")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button navigate={return_path(@return_to, @member)}>{gettext("Cancel")}</.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>
|
</.form>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
@ -106,8 +238,8 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
id -> Ash.get!(Mv.Membership.Member, id)
|
id -> Ash.get!(Mv.Membership.Member, id)
|
||||||
end
|
end
|
||||||
|
|
||||||
action = if is_nil(member), do: "New", else: "Edit"
|
page_title =
|
||||||
page_title = action <> " " <> "Member"
|
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|
|
@ -213,5 +345,37 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp return_path("index", _member), do: ~p"/members"
|
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}"
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias MvWeb.MemberLive.Index.Formatter
|
alias MvWeb.MemberLive.Index.Formatter
|
||||||
|
alias MvWeb.Helpers.DateFormatter
|
||||||
|
|
||||||
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
||||||
@custom_field_prefix "custom_field_"
|
@custom_field_prefix "custom_field_"
|
||||||
|
|
@ -937,4 +938,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
Map.get(visibility_config, Atom.to_string(field), true)
|
Map.get(visibility_config, Atom.to_string(field), true)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Public helper function to format dates for use in templates
|
||||||
|
def format_date(date), do: DateFormatter.format_date(date)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,7 @@
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{member.join_date}
|
{MvWeb.MemberLive.Index.format_date(member.join_date)}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={member} label={gettext("Paid")}>
|
<:col :let={member} label={gettext("Paid")}>
|
||||||
<span class={[
|
<span class={[
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ defmodule MvWeb.MemberLive.Index.Formatter do
|
||||||
formats them appropriately for display in the UI.
|
formats them appropriately for display in the UI.
|
||||||
"""
|
"""
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
alias MvWeb.Helpers.DateFormatter
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Formats a custom field value for display.
|
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, _) when value == false, do: gettext("No")
|
||||||
defp format_value_by_type(value, :boolean, _), do: to_string(value)
|
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
|
defp format_value_by_type(value, :date, _) when is_binary(value) do
|
||||||
case Date.from_iso8601(value) do
|
case Date.from_iso8601(value) do
|
||||||
{:ok, date} -> Date.to_string(date)
|
{:ok, date} -> DateFormatter.format_date(date)
|
||||||
_ -> value
|
_ -> value
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,16 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
LiveView for displaying a single member's details.
|
LiveView for displaying a single member's details.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- Display all member information (personal, contact, address)
|
- Display all member information in grouped sections
|
||||||
- Show linked user account (if exists)
|
- Tab navigation for future features (Payments)
|
||||||
- Display custom field values
|
- Show custom field values with type-based formatting
|
||||||
- Navigate to edit form
|
- Navigate to edit form
|
||||||
- Return to member list
|
- Return to member list
|
||||||
|
|
||||||
## Displayed Information
|
## Sections
|
||||||
- Basic: name, email, dates (join, exit)
|
- Personal Data: Name, address, contact information, membership dates, notes
|
||||||
- Contact: phone number
|
- Custom Fields: Dynamic fields in uniform grid layout (sorted by name)
|
||||||
- Address: street, house number, postal code, city
|
- Payment Data: Mockup section with placeholder data
|
||||||
- Status: paid flag
|
|
||||||
- Relationships: linked user account
|
|
||||||
- Custom: dynamic custom field values from CustomFields
|
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
- Back to member list
|
- Back to member list
|
||||||
|
|
@ -28,66 +25,150 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<%!-- Header with Back button, Name, and Edit button --%>
|
||||||
{@member.first_name} {@member.last_name}
|
<div class="flex items-center justify-between gap-4 pb-4">
|
||||||
<:subtitle>{gettext("This is a member record from your database.")}</:subtitle>
|
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
|
||||||
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
|
{gettext("Back")}
|
||||||
|
</.button>
|
||||||
|
|
||||||
<:actions>
|
<h1 class="text-2xl font-bold text-center flex-1">
|
||||||
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
|
{@member.first_name} {@member.last_name}
|
||||||
<.icon name="hero-arrow-left" />
|
</h1>
|
||||||
<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>
|
|
||||||
|
|
||||||
<.list>
|
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
|
||||||
<:item title={gettext("Id")}>{@member.id}</:item>
|
{gettext("Edit Member")}
|
||||||
<:item title={gettext("First Name")}>{@member.first_name}</:item>
|
</.button>
|
||||||
<:item title={gettext("Last Name")}>{@member.last_name}</:item>
|
</div>
|
||||||
<:item title={gettext("Email")}>{@member.email}</: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>
|
|
||||||
|
|
||||||
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
|
<%!-- Tab Navigation --%>
|
||||||
<.generic_list items={
|
<div role="tablist" class="tabs tabs-bordered mb-6">
|
||||||
Enum.map(@member.custom_field_values, fn cfv ->
|
<button role="tab" class="tab tab-active" aria-selected="true">
|
||||||
{
|
<.icon name="hero-identification" class="size-4 mr-2" />
|
||||||
# name
|
{gettext("Contact Data")}
|
||||||
cfv.custom_field && cfv.custom_field.name,
|
</button>
|
||||||
# value
|
<button role="tab" class="tab" disabled aria-disabled="true" title={gettext("Coming soon")}>
|
||||||
case cfv.value do
|
<.icon name="hero-credit-card" class="size-4 mr-2" />
|
||||||
%{value: v} -> v
|
{gettext("Payments")}
|
||||||
v -> v
|
</button>
|
||||||
end
|
</div>
|
||||||
}
|
|
||||||
end)
|
<%!-- 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>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -114,4 +195,120 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
|
|
||||||
defp page_title(:show), do: gettext("Show Member")
|
defp page_title(:show), do: gettext("Show Member")
|
||||||
defp page_title(:edit), do: gettext("Edit Member")
|
defp page_title(:edit), do: gettext("Edit Member")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# 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
|
end
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
|
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
|
||||||
</.header>
|
</.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" />
|
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||||
|
|
||||||
<!-- Password Section -->
|
<!-- Password Section -->
|
||||||
|
|
@ -61,7 +61,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<%= if @show_password_fields do %>
|
<%= 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
|
<.input
|
||||||
field={@form[:password]}
|
field={@form[:password]}
|
||||||
label={gettext("Password")}
|
label={gettext("Password")}
|
||||||
|
|
@ -83,7 +83,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
<div class="text-sm text-gray-600">
|
<div class="text-sm text-gray-600">
|
||||||
<p><strong>{gettext("Password requirements")}:</strong></p>
|
<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("At least 8 characters")}</li>
|
||||||
<li>{gettext("Include both letters and numbers")}</li>
|
<li>{gettext("Include both letters and numbers")}</li>
|
||||||
<li>{gettext("Consider using special characters")}</li>
|
<li>{gettext("Consider using special characters")}</li>
|
||||||
|
|
@ -91,7 +91,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if @user do %>
|
<%= 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">
|
<p class="text-sm text-orange-800">
|
||||||
<strong>{gettext("Admin Note")}:</strong> {gettext(
|
<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."
|
"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>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= if @user do %>
|
<%= 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">
|
<p class="text-sm text-blue-800">
|
||||||
<strong>{gettext("Note")}:</strong> {gettext(
|
<strong>{gettext("Note")}:</strong> {gettext(
|
||||||
"Check 'Change Password' above to set a new password for this user."
|
"Check 'Change Password' above to set a new password for this user."
|
||||||
|
|
@ -110,7 +110,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% 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">
|
<p class="text-sm text-yellow-800">
|
||||||
<strong>{gettext("Note")}:</strong> {gettext(
|
<strong>{gettext("Note")}:</strong> {gettext(
|
||||||
"User will be created without a password. Check 'Set Password' to add one."
|
"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 -->
|
<!-- Member Linking Section -->
|
||||||
<div class="mt-6">
|
<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 %>
|
<%= if @user && @user.member && !@unlink_member do %>
|
||||||
<!-- Show linked member with unlink button -->
|
<!-- 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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-green-900">
|
<p class="font-medium text-green-900">
|
||||||
|
|
@ -147,7 +147,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= if @unlink_member do %>
|
<%= if @unlink_member do %>
|
||||||
<!-- Show unlink pending message -->
|
<!-- 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">
|
<p class="text-sm text-yellow-800">
|
||||||
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
||||||
"Member will be unlinked when you save. Cannot select new member until saved."
|
"Member will be unlinked when you save. Cannot select new member until saved."
|
||||||
|
|
@ -219,7 +219,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
<%= 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">
|
<p class="text-sm text-yellow-800">
|
||||||
<strong>{gettext("Note")}:</strong> {gettext(
|
<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."
|
"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 %>
|
<%= if @selected_member_id && @selected_member_name do %>
|
||||||
<div
|
<div
|
||||||
id="member-selected"
|
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">
|
<p class="text-sm text-blue-800">
|
||||||
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-blue-600 mt-1">
|
<p class="mt-1 text-xs text-blue-600">
|
||||||
{gettext("Save to confirm linking.")}
|
{gettext("Save to confirm linking.")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -245,10 +245,12 @@ defmodule MvWeb.UserLive.Form do
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
<div class="mt-4">
|
||||||
{gettext("Save User")}
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||||
</.button>
|
{gettext("Save User")}
|
||||||
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
|
</.button>
|
||||||
|
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
|
||||||
|
</div>
|
||||||
</.form>
|
</.form>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@
|
||||||
>
|
>
|
||||||
{user.email}
|
{user.email}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id}</:col>
|
|
||||||
<:col :let={user} label={gettext("Linked Member")}>
|
<:col :let={user} label={gettext("Linked Member")}>
|
||||||
<%= if user.member do %>
|
<%= if user.member do %>
|
||||||
{user.member.first_name} {user.member.last_name}
|
{user.member.first_name} {user.member.last_name}
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,7 @@ defmodule MvWeb.UserLive.Show do
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.list>
|
<.list>
|
||||||
<:item title={gettext("ID")}>{@user.id}</:item>
|
|
||||||
<:item title={gettext("Email")}>{@user.email}</: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")}>
|
<:item title={gettext("Password Authentication")}>
|
||||||
{if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
|
{if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
|
||||||
</:item>
|
</:item>
|
||||||
|
|
@ -56,13 +54,13 @@ defmodule MvWeb.UserLive.Show do
|
||||||
<%= if @user.member do %>
|
<%= if @user.member do %>
|
||||||
<.link
|
<.link
|
||||||
navigate={~p"/members/#{@user.member}"}
|
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}
|
{@user.member.first_name} {@user.member.last_name}
|
||||||
</.link>
|
</.link>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="text-gray-500 italic">{gettext("No member linked")}</span>
|
<span class="italic text-gray-500">{gettext("No member linked")}</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</:item>
|
</:item>
|
||||||
</.list>
|
</.list>
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,11 @@ defmodule MvWeb.Router do
|
||||||
|
|
||||||
live "/settings", GlobalSettingsLive
|
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
|
post "/set_locale", LocaleController, :set_locale
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -90,8 +90,6 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
# Verify we're on the correct profile page with OIDC specific information
|
# Verify we're on the correct profile page with OIDC specific information
|
||||||
{:ok, _profile_view, html} = live(conn, "/users/#{user.id}")
|
{:ok, _profile_view, html} = live(conn, "/users/#{user.id}")
|
||||||
assert html =~ to_string(user.email)
|
assert html =~ to_string(user.email)
|
||||||
# OIDC ID should be visible
|
|
||||||
assert html =~ "oidc_123"
|
|
||||||
# Password auth should be disabled for OIDC users
|
# Password auth should be disabled for OIDC users
|
||||||
assert html =~ "Not enabled"
|
assert html =~ "Not enabled"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -231,8 +231,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
|
||||||
# Date should be displayed in readable format
|
# Date should be displayed in European format (dd.mm.yyyy)
|
||||||
assert html =~ "1990" or html =~ "1990-05-15" or html =~ "15.05.1990"
|
assert html =~ "15.05.1990"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "formats email custom field values correctly", %{conn: conn, member1: _member1} do
|
test "formats email custom field values correctly", %{conn: conn, member1: _member1} do
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,6 @@ defmodule MvWeb.UserLive.IndexTest do
|
||||||
|
|
||||||
assert html =~ "alice@example.com"
|
assert html =~ "alice@example.com"
|
||||||
assert html =~ "bob@example.com"
|
assert html =~ "bob@example.com"
|
||||||
assert html =~ "alice123"
|
|
||||||
assert html =~ "bob456"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows correct action links", %{conn: conn} do
|
test "shows correct action links", %{conn: conn} do
|
||||||
|
|
@ -386,10 +384,6 @@ defmodule MvWeb.UserLive.IndexTest do
|
||||||
|
|
||||||
# Should still show the table structure
|
# Should still show the table structure
|
||||||
assert html =~ "Email"
|
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
|
end
|
||||||
|
|
||||||
test "handles users with missing OIDC ID", %{conn: conn} do
|
test "handles users with missing OIDC ID", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,13 @@ defmodule MvWeb.ConnCase do
|
||||||
end
|
end
|
||||||
|
|
||||||
setup tags do
|
setup tags do
|
||||||
Mv.DataCase.setup_sandbox(tags)
|
pid = Mv.DataCase.setup_sandbox(tags)
|
||||||
{:ok, conn: Phoenix.ConnTest.build_conn()}
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,12 @@ defmodule Mv.DataCase do
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Sets up the sandbox based on the test tags.
|
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
|
def setup_sandbox(tags) do
|
||||||
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Mv.Repo, shared: not tags[:async])
|
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Mv.Repo, shared: not tags[:async])
|
||||||
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
|
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
|
||||||
|
pid
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue