diff --git a/.drone.yml b/.drone.yml index 483a08a..ee0bc41 100644 --- a/.drone.yml +++ b/.drone.yml @@ -4,7 +4,7 @@ name: check services: - name: postgres - image: docker.io/library/postgres:17.6 + image: docker.io/library/postgres:17.7 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -57,7 +57,7 @@ steps: - mix gettext.extract --check-up-to-date - name: wait_for_postgres - image: docker.io/library/postgres:17.6 + image: docker.io/library/postgres:17.7 commands: # Wait for postgres to become available - | @@ -166,7 +166,7 @@ environment: steps: - name: renovate - image: renovate/renovate:41.173 + image: renovate/renovate:41.151 environment: RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_TOKEN: diff --git a/.gitignore b/.gitignore index 9517a21..63ff39e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,3 @@ npm-debug.log .env .elixir_ls/ - -# Docker secrets directory (generated by `just init-secrets`) -/secrets/ diff --git a/.tool-versions b/.tool-versions index 98239f3..60315fc 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ elixir 1.18.3-otp-27 erlang 27.3.4 -just 1.43.1 +just 1.43.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b4a37..71d9147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CopyToClipboard JavaScript hook with fallback for older browsers - Button shows count of visible selected members (respects search/filter) - German/English translations -- Docker secrets support via `_FILE` environment variables for all sensitive configuration (SECRET_KEY_BASE, TOKEN_SIGNING_SECRET, OIDC_CLIENT_SECRET, DATABASE_URL, DATABASE_PASSWORD) ### Fixed - Email validation false positive when linking user and member with identical emails (#168 Problem #4) diff --git a/Justfile b/Justfile index b835cf4..a91c0e4 100644 --- a/Justfile +++ b/Justfile @@ -1,7 +1,4 @@ set dotenv-load := true -set export := true - -MIX_QUIET := "1" run: install-dependencies start-database migrate-database seed-database mix phx.server @@ -93,27 +90,4 @@ clean: remove-gettext-conflicts: #!/usr/bin/env bash set -euo pipefail - find priv/gettext -type f -exec sed -i '/^<<<<<<>>>>>>/d; /^%%%%%%%/d; /^++++++/d; s/^+//; s/^-//' {} \; - -# Production environment commands -# ================================ - -# Initialize secrets directory with generated secrets (only if not exists) -init-prod-secrets: - #!/usr/bin/env bash - set -euo pipefail - if [ -d "secrets" ]; then - echo "Secrets directory already exists. Skipping generation." - exit 0 - fi - echo "Creating secrets directory and generating secrets..." - mkdir -p secrets - mix phx.gen.secret > secrets/secret_key_base.txt - mix phx.gen.secret > secrets/token_signing_secret.txt - openssl rand -base64 32 | tr -d '\n' > secrets/db_password.txt - touch secrets/oidc_client_secret.txt - echo "Secrets generated in ./secrets/" - -# Start production environment with Docker Compose -start-prod: init-prod-secrets - docker compose -f docker-compose.prod.yml up -d \ No newline at end of file + find priv/gettext -type f -exec sed -i '/^<<<<<<< HEAD$/d; /^=======$/d; /^>>>>>>>/d' {} \; \ No newline at end of file diff --git a/README.md b/README.md index 090f4e9..6db7980 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Our philosophy: **software should help people spend less time on administration - 🚧 Sorting & filtering - 🚧 Roles & permissions (e.g. board, treasurer) - βœ… Custom fields (flexible per club needs) -- βœ… SSO via OIDC (works with Authentik, Rauthy, Keycloak, etc.) +- βœ… SSO via OIDC (tested with Rauthy) - 🚧 Self-service & online application - 🚧 Accessibility, GDPR, usability improvements - 🚧 Email sending @@ -147,26 +147,7 @@ Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance i 5. copy client secret to `.env` file 6. abort and run `just run` again -Now you can log in to Mila via OIDC! - -### OIDC with other providers (Authentik, Keycloak, etc.) - -Mila works with any OIDC-compliant provider. The internal strategy is named `:rauthy`, but this is just a name β€” it works with any provider. - -**Important:** The redirect URI must always end with `/auth/user/rauthy/callback`. - -Example for Authentik: -1. Create an OAuth2/OpenID Provider in Authentik -2. Set the redirect URI to: `https://your-domain.com/auth/user/rauthy/callback` -3. Configure environment variables: - ```bash - DOMAIN=your-domain.com # or PHX_HOST=your-domain.com - OIDC_CLIENT_ID=your-client-id - OIDC_BASE_URL=https://auth.example.com/application/o/your-app - OIDC_CLIENT_SECRET=your-client-secret # or use OIDC_CLIENT_SECRET_FILE - ``` - -The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/callback` if not explicitly set. +Now you can log in to Mila via OIDC! ## βš™οΈ Configuration @@ -229,20 +210,13 @@ For testing the production Docker build locally: # Required variables: SECRET_KEY_BASE= TOKEN_SIGNING_SECRET= - DOMAIN=localhost # or PHX_HOST=localhost + PHX_HOST=localhost - # Optional OIDC configuration: + # Optional (have defaults in docker-compose.prod.yml): # OIDC_CLIENT_ID=mv # OIDC_BASE_URL=http://localhost:8080/auth/v1 - # OIDC_CLIENT_SECRET= - # OIDC_REDIRECT_URI is auto-generated as https://{DOMAIN}/auth/user/rauthy/callback - - # Alternative: Use _FILE variables for Docker secrets (takes priority over regular vars): - # SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base - # TOKEN_SIGNING_SECRET_FILE=/run/secrets/token_signing_secret - # OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret - # DATABASE_URL_FILE=/run/secrets/database_url - # DATABASE_PASSWORD_FILE=/run/secrets/database_password + # OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback + # OIDC_CLIENT_SECRET= ``` 3. **Start development environment** (for Rauthy): @@ -276,7 +250,7 @@ For actual production deployment: - Set `OIDC_BASE_URL` to your production OIDC provider - Configure proper Docker networks 3. **Set up SSL/TLS** (e.g., via reverse proxy like Nginx/Traefik) -4. **Use secure secrets management** β€” 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. +4. **Use secure secrets management** (environment variables, Docker secrets, vault) 5. **Configure database backups** diff --git a/config/runtime.exs b/config/runtime.exs index 06a2cd8..c50356c 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -7,75 +7,6 @@ import Config # any compile-time configuration in here, as it won't be applied. # The block below contains prod specific runtime configuration. -# Helper function to read environment variables with Docker secrets support. -# Supports the _FILE suffix pattern: if VAR_FILE is set, reads the value from -# that file path. Otherwise falls back to VAR directly. -# VAR_FILE takes priority and must contain the full absolute path to the secret file. -get_env_or_file = fn var_name, default -> - file_var = "#{var_name}_FILE" - - case System.get_env(file_var) do - nil -> - System.get_env(var_name, default) - - file_path -> - case File.read(file_path) do - {:ok, content} -> - String.trim_trailing(content) - - {:error, reason} -> - raise """ - Failed to read secret from file specified in #{file_var}="#{file_path}". - Error: #{inspect(reason)} - """ - end - end -end - -# Same as get_env_or_file but raises if the value is not set -get_env_or_file! = fn var_name, error_message -> - case get_env_or_file.(var_name, nil) do - nil -> raise error_message - value -> value - end -end - -# Build database URL from individual components or use DATABASE_URL directly. -# Supports both approaches: -# 1. DATABASE_URL (or DATABASE_URL_FILE) - full connection URL -# 2. Separate vars: DATABASE_HOST, DATABASE_USER, DATABASE_PASSWORD (or _FILE), DATABASE_NAME, DATABASE_PORT -build_database_url = fn -> - case get_env_or_file.("DATABASE_URL", nil) do - nil -> - # Build URL from separate components - host = - System.get_env("DATABASE_HOST") || - raise "DATABASE_HOST is required when DATABASE_URL is not set" - - user = - System.get_env("DATABASE_USER") || - raise "DATABASE_USER is required when DATABASE_URL is not set" - - password = - get_env_or_file!.("DATABASE_PASSWORD", """ - DATABASE_PASSWORD or DATABASE_PASSWORD_FILE is required when DATABASE_URL is not set. - """) - - database = - System.get_env("DATABASE_NAME") || - raise "DATABASE_NAME is required when DATABASE_URL is not set" - - port = System.get_env("DATABASE_PORT", "5432") - - # URL-encode the password to handle special characters - encoded_password = URI.encode_www_form(password) - "ecto://#{user}:#{encoded_password}@#{host}:#{port}/#{database}" - - url -> - url - end -end - # ## Using releases # # If you use `mix release`, you need to explicitly enable the server @@ -90,7 +21,12 @@ if System.get_env("PHX_SERVER") do end if config_env() == :prod do - database_url = build_database_url.() + 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: [] @@ -105,72 +41,45 @@ if config_env() == :prod do # want to use a different value for prod and you most likely don't want # to check this value into version control, so we use an environment # variable instead. - # Supports SECRET_KEY_BASE or SECRET_KEY_BASE_FILE for Docker secrets. secret_key_base = - get_env_or_file!.("SECRET_KEY_BASE", """ - environment variable SECRET_KEY_BASE (or SECRET_KEY_BASE_FILE) is missing. - You can generate one by calling: mix phx.gen.secret - """) - - # PHX_HOST or DOMAIN can be used to set the host for the application. - # DOMAIN is commonly used in deployment environments (e.g., Portainer templates). - host = - System.get_env("PHX_HOST") || - System.get_env("DOMAIN") || - raise "Please define the PHX_HOST or DOMAIN environment variable." + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + host = System.get_env("PHX_HOST") || raise "Please define the PHX_HOST environment variable." port = String.to_integer(System.get_env("PORT") || "4000") config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") - # OIDC configuration (works with any OIDC provider: Authentik, Rauthy, Keycloak, etc.) - # Note: The strategy is named :rauthy internally, but works with any OIDC provider. - # The redirect_uri callback path is always /auth/user/rauthy/callback regardless of provider. - # - # Supports OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE for Docker secrets. - # OIDC_CLIENT_SECRET is required only if OIDC is being used (indicated by explicit OIDC env vars). - oidc_base_url = System.get_env("OIDC_BASE_URL") - oidc_client_id = System.get_env("OIDC_CLIENT_ID") - oidc_in_use = not is_nil(oidc_base_url) or not is_nil(oidc_client_id) - - client_secret = - if oidc_in_use do - get_env_or_file!.("OIDC_CLIENT_SECRET", """ - environment variable OIDC_CLIENT_SECRET (or OIDC_CLIENT_SECRET_FILE) is missing. - This is required when OIDC authentication is configured (OIDC_BASE_URL or OIDC_CLIENT_ID is set). - """) - else - get_env_or_file.("OIDC_CLIENT_SECRET", nil) - end - - # Build redirect_uri: use OIDC_REDIRECT_URI if set, otherwise build from host. - # Uses HTTPS since production runs behind TLS termination. - default_redirect_uri = "https://#{host}/auth/user/rauthy/callback" - + # Rauthy OIDC configuration config :mv, :rauthy, - client_id: oidc_client_id || "mv", - base_url: oidc_base_url || "http://localhost:8080/auth/v1", - client_secret: client_secret, - redirect_uri: System.get_env("OIDC_REDIRECT_URI") || default_redirect_uri + client_id: System.get_env("OIDC_CLIENT_ID") || "mv", + base_url: System.get_env("OIDC_BASE_URL") || "http://localhost:8080/auth/v1", + client_secret: System.get_env("OIDC_CLIENT_SECRET"), + redirect_uri: + System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback" # Token signing secret from environment variable # This overrides the placeholder value set in prod.exs - # Supports TOKEN_SIGNING_SECRET or TOKEN_SIGNING_SECRET_FILE for Docker secrets. token_signing_secret = - get_env_or_file!.("TOKEN_SIGNING_SECRET", """ - environment variable TOKEN_SIGNING_SECRET (or TOKEN_SIGNING_SECRET_FILE) is missing. - You can generate one by calling: mix phx.gen.secret - """) + System.get_env("TOKEN_SIGNING_SECRET") || + raise """ + environment variable TOKEN_SIGNING_SECRET is missing. + You can generate one by calling: mix phx.gen.secret + """ config :mv, :token_signing_secret, token_signing_secret config :mv, MvWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], http: [ - # Bind on all IPv4 interfaces. - # Use {0, 0, 0, 0, 0, 0, 0, 0} for IPv6, or {127, 0, 0, 1} for localhost only. + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 - ip: {0, 0, 0, 0}, + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0}, port: port ], secret_key_base: secret_key_base, diff --git a/config/test.exs b/config/test.exs index 2c4d2ba..bcb55eb 100644 --- a/config/test.exs +++ b/config/test.exs @@ -45,6 +45,3 @@ config :mv, :token_signing_secret, "test_secret_key_for_ash_authentication_token config :mv, :session_identifier, :unsafe config :mv, :require_token_presence_for_authentication, false - -# Enable SQL Sandbox for async LiveView tests -config :mv, :sql_sandbox, true diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b4b7a1f..0bb2840 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -2,32 +2,21 @@ services: app: image: git.local-it.org/local-it/mitgliederverwaltung:latest container_name: mv-prod-app - ports: - - "4001:4001" + # Use host network for local testing to access localhost:8080 (Rauthy) + # In real production, remove this and use external OIDC provider + network_mode: host environment: - # Database configuration using separate variables - # Use Docker service name for internal networking - DATABASE_HOST: "db-prod" - DATABASE_PORT: "5432" - DATABASE_USER: "postgres" - DATABASE_NAME: "mv_prod" - DATABASE_PASSWORD_FILE: "/run/secrets/db_password" - # Phoenix secrets via Docker secrets - SECRET_KEY_BASE_FILE: "/run/secrets/secret_key_base" - TOKEN_SIGNING_SECRET_FILE: "/run/secrets/token_signing_secret" - PHX_HOST: "${PHX_HOST:-localhost}" + DATABASE_URL: "ecto://postgres:postgres@localhost:5001/mv_prod" + SECRET_KEY_BASE: "${SECRET_KEY_BASE}" + TOKEN_SIGNING_SECRET: "${TOKEN_SIGNING_SECRET}" + PHX_HOST: "${PHX_HOST}" PORT: "4001" PHX_SERVER: "true" - # Rauthy OIDC config - use host.docker.internal to reach host services + # Rauthy OIDC config - uses localhost because of host network mode OIDC_CLIENT_ID: "mv" - OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1" - OIDC_CLIENT_SECRET_FILE: "/run/secrets/oidc_client_secret" + OIDC_BASE_URL: "http://localhost:8080/auth/v1" + OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}" OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback" - secrets: - - db_password - - secret_key_base - - token_signing_secret - - oidc_client_secret depends_on: - db-prod restart: unless-stopped @@ -37,25 +26,13 @@ services: container_name: mv-prod-db environment: POSTGRES_USER: postgres - POSTGRES_PASSWORD_FILE: /run/secrets/db_password + POSTGRES_PASSWORD: postgres POSTGRES_DB: mv_prod - secrets: - - db_password volumes: - postgres_data_prod:/var/lib/postgresql/data ports: - "5001:5432" restart: unless-stopped -secrets: - db_password: - file: ./secrets/db_password.txt - secret_key_base: - file: ./secrets/secret_key_base.txt - token_signing_secret: - file: ./secrets/token_signing_secret.txt - oidc_client_secret: - file: ./secrets/oidc_client_secret.txt - volumes: postgres_data_prod: diff --git a/docker-compose.yml b/docker-compose.yml index 56876f2..b10ab22 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ networks: services: db: - image: postgres:17.6-alpine + image: postgres:17.7-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres diff --git a/docs/contributions-architecture.md b/docs/contributions-architecture.md deleted file mode 100644 index 3718a3b..0000000 --- a/docs/contributions-architecture.md +++ /dev/null @@ -1,653 +0,0 @@ -# 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** - diff --git a/docs/contributions-overview.md b/docs/contributions-overview.md deleted file mode 100644 index e0c4bc8..0000000 --- a/docs/contributions-overview.md +++ /dev/null @@ -1,527 +0,0 @@ -# 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 diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 2f86f5e..609523c 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -187,16 +187,10 @@ **Current State:** - βœ… Basic "paid" boolean field on members -- βœ… **UI Mock-ups for Contribution Types & Settings** (2025-12-02) - ⚠️ No payment tracking **Open Issues:** - [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority) -- [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Contribution Mockup Pages (Preview) - -**Mock-Up Pages (Non-Functional Preview):** -- `/contribution_types` - Contribution Types Management -- `/contribution_settings` - Global Contribution Settings **Missing Features:** - ❌ Membership fee configuration diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index dbc62b2..749740d 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -54,9 +54,6 @@ defmodule Mv.Accounts.User do auth_method :client_secret_jwt code_verifier true - # Request email and profile scopes from OIDC provider (required for Authentik, Keycloak, etc.) - authorization_params scope: "openid email profile" - # id_token_signed_response_alg "EdDSA" #-> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87 end @@ -72,7 +69,7 @@ defmodule Mv.Accounts.User do # Default actions for framework/tooling integration: # - :read -> Standard read used across the app and by admin tooling. # - :destroy-> Standard delete used by admin tooling and maintenance tasks. - # + # # NOTE: :create is INTENTIONALLY excluded from defaults! # Using a default :create would bypass email-synchronization logic. # Always use one of these explicit create actions instead: @@ -188,9 +185,7 @@ defmodule Mv.Accounts.User do oidc_user_info = Ash.Changeset.get_argument(changeset, :oidc_user_info) # Get the new email from OIDC user_info - # Support both "email" (standard OIDC) and "preferred_username" (Rauthy) - new_email = - Map.get(oidc_user_info, "email") || Map.get(oidc_user_info, "preferred_username") + new_email = Map.get(oidc_user_info, "preferred_username") changeset |> Ash.Changeset.change_attribute(:oidc_id, oidc_id) @@ -244,11 +239,8 @@ defmodule Mv.Accounts.User do change fn changeset, _ctx -> user_info = Ash.Changeset.get_argument(changeset, :user_info) - # Support both "email" (standard OIDC like Authentik, Keycloak) and "preferred_username" (Rauthy) - email = user_info["email"] || user_info["preferred_username"] - changeset - |> Ash.Changeset.change_attribute(:email, email) + |> Ash.Changeset.change_attribute(:email, user_info["preferred_username"]) |> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"]) end diff --git a/lib/membership/member.ex b/lib/membership/member.ex index b788dc9..8d271d7 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -401,70 +401,6 @@ defmodule Mv.Membership.Member do identity :unique_email, [:email] end - @doc """ - Checks if a member field should be shown in the overview. - - Reads the visibility configuration from Settings resource. If a field is not - configured in settings, it defaults to `true` (visible). - - ## Parameters - - `field` - Atom representing the member field name (e.g., `:email`, `:street`) - - ## Returns - - `true` if the field should be shown in overview (default) - - `false` if the field is configured as hidden in settings - - ## Examples - - iex> Member.show_in_overview?(:email) - true - - iex> Member.show_in_overview?(:street) - true # or false if configured in settings - - """ - @spec show_in_overview?(atom()) :: boolean() - def show_in_overview?(field) when is_atom(field) do - case Mv.Membership.get_settings() do - {:ok, settings} -> - visibility_config = settings.member_field_visibility || %{} - # Normalize map keys to atoms (JSONB may return string keys) - normalized_config = normalize_visibility_config(visibility_config) - - # Get value from normalized config, default to true - Map.get(normalized_config, field, true) - - {:error, _} -> - # If settings can't be loaded, default to visible - true - end - end - - def show_in_overview?(_), do: true - - # Normalizes visibility config map keys from strings to atoms. - # JSONB in PostgreSQL converts atom keys to string keys when storing. - defp normalize_visibility_config(config) when is_map(config) do - Enum.reduce(config, %{}, fn - {key, value}, acc when is_atom(key) -> - Map.put(acc, key, value) - - {key, value}, acc when is_binary(key) -> - try do - atom_key = String.to_existing_atom(key) - Map.put(acc, atom_key, value) - rescue - ArgumentError -> - acc - end - - _, acc -> - acc - end) - end - - defp normalize_visibility_config(_), do: %{} - @doc """ Performs fuzzy search on members using PostgreSQL trigram similarity. diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 7bfb07b..334bcc1 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -18,17 +18,5 @@ defmodule Mv.Constants do :postal_code ] - @custom_field_prefix "custom_field_" - def member_fields, do: @member_fields - - @doc """ - Returns the prefix used for custom field keys in field visibility maps. - - ## Examples - - iex> Mv.Constants.custom_field_prefix() - "custom_field_" - """ - def custom_field_prefix, do: @custom_field_prefix end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index be64655..54a5a64 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -79,7 +79,7 @@ defmodule MvWeb.CoreComponents do

{msg}

-
@@ -119,123 +119,6 @@ defmodule MvWeb.CoreComponents do end end - @doc """ - Renders a dropdown menu. - - ## Examples - - <.dropdown_menu items={@items} open={@open} phx_target={@myself} /> - """ - attr :id, :string, default: "dropdown-menu" - attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps" - attr :button_label, :string, default: "Dropdown" - attr :icon, :string, default: nil - attr :checkboxes, :boolean, default: false - attr :selected, :map, default: %{} - attr :open, :boolean, default: false, doc: "Whether the dropdown is open" - attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons" - attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events" - - def dropdown_menu(assigns) do - ~H""" -
- - - -
- """ - end - @doc """ Renders an input with label and error messages. @@ -485,63 +368,61 @@ defmodule MvWeb.CoreComponents do end ~H""" -
- - - - - - - - - - - - + + + +
{col[:label]} - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} - field={"custom_field_#{dyn_col[:custom_field].id}"} - label={dyn_col[:custom_field].name} - sort_field={@sort_field} - sort_order={@sort_order} - /> - - {gettext("Actions")} -
- {render_slot(col, @row_item.(row))} - - {if dyn_col[:render] do - rendered = dyn_col[:render].(@row_item.(row)) + + + + + + + + + + + + - - - -
{col[:label]} + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} + field={"custom_field_#{dyn_col[:custom_field].id}"} + label={dyn_col[:custom_field].name} + sort_field={@sort_field} + sort_order={@sort_order} + /> + + {gettext("Actions")} +
+ {render_slot(col, @row_item.(row))} + + {if dyn_col[:render] do + rendered = dyn_col[:render].(@row_item.(row)) - if rendered == "" do - "" - else - rendered - end - else + if rendered == "" do "" - end} - -
- <%= for action <- @action do %> - {render_slot(action, @row_item.(row))} - <% end %> -
-
- + else + rendered + end + else + "" + end} +
+
+ <%= for action <- @action do %> + {render_slot(action, @row_item.(row))} + <% end %> +
+
""" end diff --git a/lib/mv_web/components/layouts/navbar.ex b/lib/mv_web/components/layouts/navbar.ex index 4246c99..7ff7f25 100644 --- a/lib/mv_web/components/layouts/navbar.ex +++ b/lib/mv_web/components/layouts/navbar.ex @@ -23,19 +23,8 @@ defmodule MvWeb.Layouts.Navbar do {@club_name}
diff --git a/lib/mv_web/endpoint.ex b/lib/mv_web/endpoint.ex index d1b4247..97dcae4 100644 --- a/lib/mv_web/endpoint.ex +++ b/lib/mv_web/endpoint.ex @@ -39,11 +39,6 @@ defmodule MvWeb.Endpoint do plug Phoenix.Ecto.CheckRepoStatus, otp_app: :mv end - # Enable Ecto SQL Sandbox in test environment for async tests - if Application.compile_env(:mv, :sql_sandbox) do - plug Phoenix.Ecto.SQL.Sandbox - end - plug Phoenix.LiveDashboard.RequestLogger, param_key: "request_logger", cookie_key: "request_logger" diff --git a/lib/mv_web/helpers/date_formatter.ex b/lib/mv_web/helpers/date_formatter.ex deleted file mode 100644 index eaa9271..0000000 --- a/lib/mv_web/helpers/date_formatter.ex +++ /dev/null @@ -1,27 +0,0 @@ -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 diff --git a/lib/mv_web/live/components/field_visibility_dropdown_component.ex b/lib/mv_web/live/components/field_visibility_dropdown_component.ex deleted file mode 100644 index 642273c..0000000 --- a/lib/mv_web/live/components/field_visibility_dropdown_component.ex +++ /dev/null @@ -1,176 +0,0 @@ -defmodule MvWeb.Components.FieldVisibilityDropdownComponent do - @moduledoc """ - LiveComponent for managing field visibility in the member overview. - - Provides an accessible dropdown menu where users can select/deselect - which member fields and custom fields are visible in the table. - - ## Props - - `:all_fields` - List of all available fields - - `:custom_fields` - List of CustomField resources - - `:selected_fields` - Map field_name β†’ boolean - - `:id` - Component ID - - ## Events sent to parent: - - `{:field_toggled, field, value}` - - `{:fields_selected, map}` - """ - - use MvWeb, :live_component - - # --------------------------------------------------------------------------- - # UPDATE - # --------------------------------------------------------------------------- - - @impl true - def update(assigns, socket) do - socket = - socket - |> assign(assigns) - |> assign_new(:open, fn -> false end) - |> assign_new(:all_fields, fn -> [] end) - |> assign_new(:custom_fields, fn -> [] end) - |> assign_new(:selected_fields, fn -> %{} end) - - {:ok, socket} - end - - # --------------------------------------------------------------------------- - # RENDER - # --------------------------------------------------------------------------- - - @impl true - def render(assigns) do - all_fields = assigns.all_fields || [] - custom_fields = assigns.custom_fields || [] - - all_items = - Enum.map(extract_member_field_keys(all_fields), fn field -> - %{ - value: field_to_string(field), - label: format_field_label(field) - } - end) ++ - Enum.map(extract_custom_field_keys(all_fields), fn field -> - %{ - value: field, - label: format_custom_field_label(field, custom_fields) - } - end) - - assigns = assign(assigns, :all_items, all_items) - - # LiveComponents require a static HTML element as root, not a function component - ~H""" -
- <.dropdown_menu - id="field-visibility-menu" - icon="hero-adjustments-horizontal" - button_label={gettext("Columns")} - items={@all_items} - checkboxes={true} - selected={@selected_fields} - open={@open} - show_select_buttons={true} - phx_target={@myself} - /> -
- """ - end - - # --------------------------------------------------------------------------- - # EVENTS (matching the Core Component API) - # --------------------------------------------------------------------------- - - @impl true - def handle_event("toggle_dropdown", _params, socket) do - {:noreply, assign(socket, :open, !socket.assigns.open)} - end - - def handle_event("close_dropdown", _params, socket) do - {:noreply, assign(socket, :open, false)} - end - - # toggle single item - def handle_event("select_item", %{"item" => item}, socket) do - current = Map.get(socket.assigns.selected_fields, item, true) - updated = Map.put(socket.assigns.selected_fields, item, !current) - - send(self(), {:field_toggled, item, !current}) - {:noreply, assign(socket, :selected_fields, updated)} - end - - # select all - def handle_event("select_all", _params, socket) do - all = - socket.assigns.all_fields - |> Enum.map(&field_to_string/1) - |> Enum.map(&{&1, true}) - |> Enum.into(%{}) - - send(self(), {:fields_selected, all}) - {:noreply, assign(socket, :selected_fields, all)} - end - - # select none - def handle_event("select_none", _params, socket) do - none = - socket.assigns.all_fields - |> Enum.map(&field_to_string/1) - |> Enum.map(&{&1, false}) - |> Enum.into(%{}) - - send(self(), {:fields_selected, none}) - {:noreply, assign(socket, :selected_fields, none)} - end - - # --------------------------------------------------------------------------- - # HELPERS (with defensive nil guards) - # --------------------------------------------------------------------------- - - defp extract_member_field_keys(nil), do: [] - - defp extract_member_field_keys(fields) do - prefix = Mv.Constants.custom_field_prefix() - - Enum.filter(fields, fn field -> - is_atom(field) || - (is_binary(field) && not String.starts_with?(field, prefix)) - end) - end - - defp extract_custom_field_keys(nil), do: [] - - defp extract_custom_field_keys(fields) do - prefix = Mv.Constants.custom_field_prefix() - - Enum.filter(fields, fn field -> - is_binary(field) && String.starts_with?(field, prefix) - end) - end - - defp field_to_string(field) when is_atom(field), do: Atom.to_string(field) - defp field_to_string(field) when is_binary(field), do: field - - defp format_field_label(field) do - field - |> field_to_string() - |> String.replace("_", " ") - |> String.split() - |> Enum.map_join(" ", &String.capitalize/1) - end - - defp format_custom_field_label(field_string, custom_fields) do - id = String.trim_leading(field_string, Mv.Constants.custom_field_prefix()) - find_custom_field_name(id, field_string, custom_fields) - end - - defp find_custom_field_name("", field_string, _custom_fields), do: field_string - - defp find_custom_field_name(id, _field_string, custom_fields) do - case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do - nil -> gettext("Custom Field %{id}", id: id) - custom_field -> custom_field.name - end - end -end diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex index 3817d90..b847308 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -19,7 +19,7 @@ defmodule MvWeb.Components.SortHeaderComponent do @impl true def render(assigns) do ~H""" -
+
- - -
- - <%!-- Periods Table --%> -
- - - - - - - - - - - - - - - - - - - - - - - -
- - {gettext("Time Period")}{gettext("Interval")}{gettext("Amount")}{gettext("Status")}{gettext("Notes")}{gettext("Actions")}
- - -
- {period.period_start} – {period.period_end} -
-
- {gettext("Current")} -
-
- {format_interval(period.interval)} - - {format_currency(period.amount)} - - <.status_badge status={period.status} /> - - - {period.notes} - - β€” - -
- <.link - href="#" - class={[ - "cursor-not-allowed", - if(period.status == :paid, do: "invisible", else: "opacity-50") - ]} - > - {gettext("Paid")} - - <.link - href="#" - class={[ - "cursor-not-allowed", - if(period.status == :suspended, do: "invisible", else: "opacity-50") - ]} - > - {gettext("Suspend")} - - <.link - href="#" - class={[ - "cursor-not-allowed", - if(period.status != :paid, do: "invisible", else: "opacity-50") - ]} - > - {gettext("Reopen")} - - <.link href="#" class="opacity-50 cursor-not-allowed"> - {gettext("Note")} - -
-
-
- - """ - end - - # Mock-up warning banner component - subtle orange style - defp mockup_warning(assigns) do - ~H""" -
- <.icon name="hero-exclamation-triangle" class="size-5 shrink-0" /> -
- {gettext("Preview Mockup")} - - – {gettext("This page is not functional and only displays the planned features.")} - -
-
- """ - end - - # Status badge component - attr :status, :atom, required: true - - defp status_badge(%{status: :paid} = assigns) do - ~H""" - - <.icon name="hero-check-circle-mini" class="size-3" /> - {gettext("Paid")} - - """ - end - - defp status_badge(%{status: :unpaid} = assigns) do - ~H""" - - <.icon name="hero-x-circle-mini" class="size-3" /> - {gettext("Unpaid")} - - """ - end - - defp status_badge(%{status: :suspended} = assigns) do - ~H""" - - <.icon name="hero-pause-circle-mini" class="size-3" /> - {gettext("Suspended")} - - """ - 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 diff --git a/lib/mv_web/live/contribution_settings_live.ex b/lib/mv_web/live/contribution_settings_live.ex deleted file mode 100644 index 713bc8c..0000000 --- a/lib/mv_web/live/contribution_settings_live.ex +++ /dev/null @@ -1,277 +0,0 @@ -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""" - - <.mockup_warning /> - - <.header> - {gettext("Contribution Settings")} - <:subtitle> - {gettext("Configure global settings for membership contributions.")} - - - -
- <%!-- Settings Form --%> -
-
-

- <.icon name="hero-cog-6-tooth" class="size-5" /> - {gettext("Global Settings")} -

- -
- <%!-- Default Contribution Type --%> -
- - -

- {gettext( - "This contribution type is automatically assigned to all new members. Can be changed individually per member." - )} -

-
- - <%!-- Include Joining Period --%> -
- -
-

- {gettext("When active: Members pay from the period of their joining.")} -

-

- {gettext("When inactive: Members pay from the next full period after joining.")} -

-
-
- -
- - -
-
-
- - <%!-- Examples Card --%> -
-
-

- <.icon name="hero-light-bulb" class="size-5" /> - {gettext("Examples")} -

- - <.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")} - /> - -
- - <.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")} - /> - -
- - <.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")} - /> - -
- - <.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")} - /> -
-
-
- - <.example_member_card /> -
- """ - end - - # Example member card with link to period view - defp example_member_card(assigns) do - ~H""" -
-
-

- <.icon name="hero-user" class="size-5" /> - {gettext("Example: Member Contribution View")} -

-

- {gettext( - "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods." - )} -

-
- <.link navigate={~p"/contributions/member/example"} class="btn btn-primary btn-sm"> - <.icon name="hero-eye" class="size-4" /> - {gettext("View Example Member")} - -
-
-
- """ - end - - # Mock-up warning banner component - subtle orange style - defp mockup_warning(assigns) do - ~H""" -
- <.icon name="hero-exclamation-triangle" class="size-5 shrink-0" /> -
- {gettext("Preview Mockup")} - - – {gettext("This page is not functional and only displays the planned features.")} - -
-
- """ - 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""" -
-

{@title}

-
-

- {gettext("Joining date")}: - {@joining_date} -

-

- {gettext("Contribution start")}: - {@start_date} -

-

- {gettext("Generated periods")}: - - {Enum.join(@periods, ", ")} - -

-
-

β†’ {@note}

-
- """ - 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 diff --git a/lib/mv_web/live/contribution_type_live/index.ex b/lib/mv_web/live/contribution_type_live/index.ex deleted file mode 100644 index 9a7b602..0000000 --- a/lib/mv_web/live/contribution_type_live/index.ex +++ /dev/null @@ -1,205 +0,0 @@ -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""" - - <.mockup_warning /> - - <.header> - {gettext("Contribution Types")} - <:subtitle> - {gettext("Manage contribution types for membership fees.")} - - <:actions> - - - - - <.table id="contribution_types" rows={@contribution_types} row_id={fn ct -> "ct-#{ct.id}" end}> - <:col :let={ct} label={gettext("Name")}> - {ct.name} -

{ct.description}

- - - <:col :let={ct} label={gettext("Amount")}> - {format_currency(ct.amount)} - - - <:col :let={ct} label={gettext("Interval")}> - {format_interval(ct.interval)} - - - <:col :let={ct} label={gettext("Members")}> - {ct.member_count} - - - <:action :let={_ct}> - - - - <:action :let={ct}> - - - - - <.info_card /> -
- """ - end - - # Mock-up warning banner component - subtle orange style - defp mockup_warning(assigns) do - ~H""" -
- <.icon name="hero-exclamation-triangle" class="size-5 shrink-0" /> -
- {gettext("Preview Mockup")} - - – {gettext("This page is not functional and only displays the planned features.")} - -
-
- """ - end - - # Info card explaining the contribution type concept - defp info_card(assigns) do - ~H""" -
-
-

- <.icon name="hero-information-circle" class="size-5" /> - {gettext("About Contribution Types")} -

-
-

- {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." - )} -

-
    -
  • - {gettext("Name & Amount")} - - {gettext("Can be changed at any time. Amount changes affect future periods only.")} -
  • -
  • - {gettext("Interval")} - - {gettext( - "Fixed after creation. Members can only switch between types with the same interval." - )} -
  • -
  • - {gettext("Deletion")} - - {gettext("Only possible if no members are assigned to this type.")} -
  • -
-
-
-
- """ - 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 diff --git a/lib/mv_web/live/custom_field_live/form.ex b/lib/mv_web/live/custom_field_live/form.ex new file mode 100644 index 0000000..99317a9 --- /dev/null +++ b/lib/mv_web/live/custom_field_live/form.ex @@ -0,0 +1,142 @@ +defmodule MvWeb.CustomFieldLive.Form do + @moduledoc """ + LiveView form for creating and editing custom fields (admin). + + ## Features + - Create new custom field definitions + - Edit existing custom fields + - Select value type from supported types + - Set immutable and required flags + - Real-time validation + + ## Form Fields + **Required:** + - name - Unique identifier (e.g., "phone_mobile", "emergency_contact") + - value_type - Data type (:string, :integer, :boolean, :date, :email) + + **Optional:** + - description - Human-readable explanation + - immutable - If true, values cannot be changed after creation (default: false) + - required - If true, all members must have this custom field (default: false) + - show_in_overview - If true, this custom field will be displayed in the member overview table (default: true) + + ## Value Type Selection + - `:string` - Text data (unlimited length) + - `:integer` - Numeric data + - `:boolean` - True/false flags + - `:date` - Date values + - `:email` - Validated email addresses + + ## Events + - `validate` - Real-time form validation + - `save` - Submit form (create or update custom field) + + ## Security + Custom field management is restricted to admin users. + """ + use MvWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <.header> + {@page_title} + <:subtitle> + {gettext("Use this form to manage custom_field records in your database.")} + + + + <.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save"> + <.input field={@form[:name]} type="text" label={gettext("Name")} /> + + <.input + field={@form[:value_type]} + type="select" + label={gettext("Value type")} + options={ + Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of] + } + /> + <.input field={@form[:description]} type="text" label={gettext("Description")} /> + <.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} /> + <.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> + <.input field={@form[:show_in_overview]} type="checkbox" label={gettext("Show in overview")} /> + + <.button phx-disable-with={gettext("Saving...")} variant="primary"> + {gettext("Save Custom field")} + + <.button navigate={return_path(@return_to, @custom_field)}>{gettext("Cancel")} + + + """ + end + + @impl true + def mount(params, _session, socket) do + custom_field = + case params["id"] do + nil -> nil + id -> Ash.get!(Mv.Membership.CustomField, id) + end + + action = if is_nil(custom_field), do: "New", else: "Edit" + page_title = action <> " " <> "Custom field" + + {:ok, + socket + |> assign(:return_to, return_to(params["return_to"])) + |> assign(custom_field: custom_field) + |> assign(:page_title, page_title) + |> assign_form()} + end + + defp return_to("show"), do: "show" + defp return_to(_), do: "index" + + @impl true + def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do + {:noreply, + assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))} + end + + def handle_event("save", %{"custom_field" => custom_field_params}, socket) do + case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do + {:ok, custom_field} -> + notify_parent({:saved, custom_field}) + + action = + case socket.assigns.form.source.type do + :create -> gettext("create") + :update -> gettext("update") + other -> to_string(other) + end + + socket = + socket + |> put_flash(:info, gettext("Custom field %{action} successfully", action: action)) + |> push_navigate(to: return_path(socket.assigns.return_to, custom_field)) + + {:noreply, socket} + + {:error, form} -> + {:noreply, assign(socket, form: form)} + end + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) + + defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do + form = + if custom_field do + AshPhoenix.Form.for_update(custom_field, :update, as: "custom_field") + else + AshPhoenix.Form.for_create(Mv.Membership.CustomField, :create, as: "custom_field") + end + + assign(socket, form: to_form(form)) + end + + defp return_path("index", _custom_field), do: ~p"/custom_fields" + defp return_path("show", custom_field), do: ~p"/custom_fields/#{custom_field.id}" +end diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex deleted file mode 100644 index 4fe8579..0000000 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ /dev/null @@ -1,127 +0,0 @@ -defmodule MvWeb.CustomFieldLive.FormComponent do - @moduledoc """ - LiveComponent form for creating and editing custom fields (embedded in settings). - - ## Features - - Create new custom field definitions - - Edit existing custom fields - - Select value type from supported types - - Set immutable and required flags - - Real-time validation - - ## Props - - `custom_field` - The custom field to edit (nil for new) - - `on_save` - Callback function to call when form is saved - - `on_cancel` - Callback function to call when form is cancelled - """ - use MvWeb, :live_component - - @impl true - def render(assigns) do - ~H""" -
-
-
- <.button - type="button" - phx-click="cancel" - phx-target={@myself} - aria-label={gettext("Back to custom field overview")} - > - <.icon name="hero-arrow-left" class="w-4 h-4" /> - -

- {if @custom_field, do: gettext("Edit Custom Field"), else: gettext("New Custom Field")} -

-
- - <.form - for={@form} - id={@id <> "-form"} - phx-change="validate" - phx-submit="save" - phx-target={@myself} - > - <.input field={@form[:name]} type="text" label={gettext("Name")} /> - - <.input - field={@form[:value_type]} - type="select" - label={gettext("Value type")} - options={ - Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of] - } - /> - <.input field={@form[:description]} type="text" label={gettext("Description")} /> - <.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} /> - <.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> - <.input - field={@form[:show_in_overview]} - type="checkbox" - label={gettext("Show in overview")} - /> - -
- <.button type="button" phx-click="cancel" phx-target={@myself}> - {gettext("Cancel")} - - <.button phx-disable-with={gettext("Saving...")} variant="primary"> - {gettext("Save Custom field")} - -
- -
-
- """ - end - - @impl true - def update(assigns, socket) do - {:ok, - socket - |> assign(assigns) - |> assign_form()} - end - - @impl true - def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do - {:noreply, - assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))} - end - - @impl true - def handle_event("save", %{"custom_field" => custom_field_params}, socket) do - case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do - {:ok, custom_field} -> - action = - case socket.assigns.form.source.type do - :create -> gettext("create") - :update -> gettext("update") - other -> to_string(other) - end - - socket.assigns.on_save.(custom_field, action) - {:noreply, socket} - - {:error, form} -> - {:noreply, assign(socket, form: form)} - end - end - - @impl true - def handle_event("cancel", _params, socket) do - socket.assigns.on_cancel.() - {:noreply, socket} - end - - defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do - form = - if custom_field do - AshPhoenix.Form.for_update(custom_field, :update, as: "custom_field") - else - AshPhoenix.Form.for_create(Mv.Membership.CustomField, :create, as: "custom_field") - end - - assign(socket, form: to_form(form)) - end -end diff --git a/lib/mv_web/live/custom_field_live/index.ex b/lib/mv_web/live/custom_field_live/index.ex new file mode 100644 index 0000000..f711323 --- /dev/null +++ b/lib/mv_web/live/custom_field_live/index.ex @@ -0,0 +1,199 @@ +defmodule MvWeb.CustomFieldLive.Index do + @moduledoc """ + LiveView for managing custom field definitions (admin). + + ## Features + - List all custom fields + - Display type information (name, value type, description) + - Show immutable and required flags + - Create new custom fields + - Edit existing custom fields + - Delete custom fields with confirmation (cascades to all custom field values) + + ## Displayed Information + - Name: Unique identifier for the custom field + - Value type: Data type constraint (string, integer, boolean, date, email) + - Description: Human-readable explanation + - Immutable: Whether custom field values can be changed after creation + - Required: Whether all members must have this custom field (future feature) + + ## Events + - `prepare_delete` - Opens deletion confirmation modal with member count + - `confirm_delete` - Executes deletion after slug verification + - `cancel_delete` - Cancels deletion and closes modal + - `update_slug_confirmation` - Updates slug input state + + ## Security + Custom field management is restricted to admin users. + Deletion requires entering the custom field's slug to prevent accidental deletions. + """ + use MvWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <.header> + Listing Custom fields + <:actions> + <.button variant="primary" navigate={~p"/custom_fields/new"}> + <.icon name="hero-plus" /> New Custom field + + + + + <.table + id="custom_fields" + rows={@streams.custom_fields} + row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end} + > + <:col :let={{_id, custom_field}} label="Name">{custom_field.name} + + <:col :let={{_id, custom_field}} label="Description">{custom_field.description} + + <:action :let={{_id, custom_field}}> +
+ <.link navigate={~p"/custom_fields/#{custom_field}"}>Show +
+ + <.link navigate={~p"/custom_fields/#{custom_field}/edit"}>Edit + + + <:action :let={{_id, custom_field}}> + <.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id})}> + Delete + + + + + <%!-- Delete Confirmation Modal --%> + + + +
+ """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Listing Custom fields") + |> assign(:show_delete_modal, false) + |> assign(:custom_field_to_delete, nil) + |> assign(:slug_confirmation, "") + |> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField))} + end + + @impl true + def handle_event("prepare_delete", %{"id" => id}, socket) do + custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count]) + + {:noreply, + socket + |> assign(:custom_field_to_delete, custom_field) + |> assign(:show_delete_modal, true) + |> assign(:slug_confirmation, "")} + end + + @impl true + def handle_event("update_slug_confirmation", %{"slug" => slug}, socket) do + {:noreply, assign(socket, :slug_confirmation, slug)} + end + + @impl true + def handle_event("confirm_delete", _params, socket) do + custom_field = socket.assigns.custom_field_to_delete + + if socket.assigns.slug_confirmation == custom_field.slug do + # Delete the custom field (CASCADE will handle custom field values) + case Ash.destroy(custom_field) do + :ok -> + {:noreply, + socket + |> put_flash(:info, "Custom field deleted successfully") + |> assign(:show_delete_modal, false) + |> assign(:custom_field_to_delete, nil) + |> assign(:slug_confirmation, "") + |> stream_delete(:custom_fields, custom_field)} + + {:error, error} -> + {:noreply, + socket + |> put_flash(:error, "Failed to delete custom field: #{inspect(error)}")} + end + else + {:noreply, + socket + |> put_flash(:error, "Slug does not match. Deletion cancelled.")} + end + end + + @impl true + def handle_event("cancel_delete", _params, socket) do + {:noreply, + socket + |> assign(:show_delete_modal, false) + |> assign(:custom_field_to_delete, nil) + |> assign(:slug_confirmation, "")} + end +end diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex deleted file mode 100644 index 50eeaae..0000000 --- a/lib/mv_web/live/custom_field_live/index_component.ex +++ /dev/null @@ -1,261 +0,0 @@ -defmodule MvWeb.CustomFieldLive.IndexComponent do - @moduledoc """ - LiveComponent for managing custom field definitions (embedded in settings). - - ## Features - - List all custom fields - - Display type information (name, value type, description) - - Show immutable and required flags - - Create new custom fields - - Edit existing custom fields - - Delete custom fields with confirmation (cascades to all custom field values) - """ - use MvWeb, :live_component - - @impl true - def render(assigns) do - ~H""" -
- <.header> - {gettext("Custom Fields")} - <:subtitle> - {gettext("These will appear in addition to other data when adding new members.")} - - <:actions> - <.button variant="primary" phx-click="new_custom_field" phx-target={@myself}> - <.icon name="hero-plus" /> {gettext("New Custom field")} - - - - - <%!-- Show form when creating or editing --%> -
- <.live_component - module={MvWeb.CustomFieldLive.FormComponent} - id={@form_id} - custom_field={@editing_custom_field} - on_save={ - fn custom_field, action -> send(self(), {:custom_field_saved, custom_field, action}) end - } - on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end} - /> -
- - <%!-- Hide table when form is visible --%> - <.table - :if={!@show_form} - id="custom_fields" - rows={@streams.custom_fields} - row_click={ - fn {_id, custom_field} -> - JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself) - end - } - > - <:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name} - - <:col :let={{_id, custom_field}} label={gettext("Value Type")}> - {custom_field.value_type} - - - <:col :let={{_id, custom_field}} label={gettext("Description")}> - {custom_field.description} - - - <:col :let={{_id, custom_field}} label={gettext("Show in Overview")}> - - {gettext("Yes")} - - - {gettext("No")} - - - - <:action :let={{_id, custom_field}}> - <.link phx-click={ - JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself) - }> - {gettext("Edit")} - - - - <:action :let={{_id, custom_field}}> - <.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)}> - {gettext("Delete")} - - - - - <%!-- Delete Confirmation Modal --%> - - - -
- """ - end - - @impl true - def update(assigns, socket) do - # If show_form is explicitly provided in assigns, reset editing state - socket = - if Map.has_key?(assigns, :show_form) and assigns.show_form == false do - socket - |> assign(:editing_custom_field, nil) - |> assign(:form_id, "custom-field-form-new") - else - socket - end - - {:ok, - socket - |> assign(assigns) - |> assign_new(:show_form, fn -> false end) - |> assign_new(:form_id, fn -> "custom-field-form-new" end) - |> assign_new(:editing_custom_field, fn -> nil end) - |> assign_new(:show_delete_modal, fn -> false end) - |> assign_new(:custom_field_to_delete, fn -> nil end) - |> assign_new(:slug_confirmation, fn -> "" end) - |> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField), reset: true)} - end - - @impl true - def handle_event("new_custom_field", _params, socket) do - {:noreply, - socket - |> assign(:show_form, true) - |> assign(:editing_custom_field, nil) - |> assign(:form_id, "custom-field-form-new")} - end - - @impl true - def handle_event("edit_custom_field", %{"id" => id}, socket) do - custom_field = Ash.get!(Mv.Membership.CustomField, id) - - {:noreply, - socket - |> assign(:show_form, true) - |> assign(:editing_custom_field, custom_field) - |> assign(:form_id, "custom-field-form-#{id}")} - end - - @impl true - def handle_event("prepare_delete", %{"id" => id}, socket) do - custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count]) - - {:noreply, - socket - |> assign(:custom_field_to_delete, custom_field) - |> assign(:show_delete_modal, true) - |> assign(:slug_confirmation, "")} - end - - @impl true - def handle_event("update_slug_confirmation", %{"slug" => slug}, socket) do - {:noreply, assign(socket, :slug_confirmation, slug)} - end - - @impl true - def handle_event("confirm_delete", _params, socket) do - custom_field = socket.assigns.custom_field_to_delete - - if socket.assigns.slug_confirmation == custom_field.slug do - case Ash.destroy(custom_field) do - :ok -> - send(self(), {:custom_field_deleted, custom_field}) - - {:noreply, - socket - |> assign(:show_delete_modal, false) - |> assign(:custom_field_to_delete, nil) - |> assign(:slug_confirmation, "") - |> stream_delete(:custom_fields, custom_field)} - - {:error, error} -> - send(self(), {:custom_field_delete_error, error}) - - {:noreply, - socket - |> assign(:show_delete_modal, false) - |> assign(:custom_field_to_delete, nil) - |> assign(:slug_confirmation, "")} - end - else - send(self(), :custom_field_slug_mismatch) - - {:noreply, - socket - |> assign(:show_delete_modal, false) - |> assign(:custom_field_to_delete, nil) - |> assign(:slug_confirmation, "")} - end - end - - @impl true - def handle_event("cancel_delete", _params, socket) do - {:noreply, - socket - |> assign(:show_delete_modal, false) - |> assign(:custom_field_to_delete, nil) - |> assign(:slug_confirmation, "")} - end -end diff --git a/lib/mv_web/live/custom_field_live/show.ex b/lib/mv_web/live/custom_field_live/show.ex new file mode 100644 index 0000000..239b844 --- /dev/null +++ b/lib/mv_web/live/custom_field_live/show.ex @@ -0,0 +1,75 @@ +defmodule MvWeb.CustomFieldLive.Show do + @moduledoc """ + LiveView for displaying a single custom field's details (admin). + + ## Features + - Display custom field definition + - Show all attributes (name, value type, description, flags) + - Navigate to edit form + - Return to custom field list + + ## Displayed Information + - ID: Internal UUID identifier + - Slug: URL-friendly identifier (auto-generated, immutable) + - Name: Unique identifier + - Value type: Data type constraint + - Description: Optional explanation + - Immutable flag: Whether values can be changed + - Required flag: Whether all members need this custom field + + ## Navigation + - Back to custom field list + - Edit custom field + + ## Security + Custom field details are restricted to admin users. + """ + use MvWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <.header> + Custom field {@custom_field.slug} + <:subtitle>This is a custom_field record from your database. + + <:actions> + <.button navigate={~p"/custom_fields"}> + <.icon name="hero-arrow-left" /> + + <.button + variant="primary" + navigate={~p"/custom_fields/#{@custom_field}/edit?return_to=show"} + > + <.icon name="hero-pencil-square" /> Edit Custom field + + + + + <.list> + <:item title="Id">{@custom_field.id} + + <:item title="Slug"> + {@custom_field.slug} +

+ {gettext("Auto-generated identifier (immutable)")} +

+ + + <:item title="Name">{@custom_field.name} + + <:item title="Description">{@custom_field.description} + +
+ """ + end + + @impl true + def mount(%{"id" => id}, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Show Custom field") + |> assign(:custom_field, Ash.get!(Mv.Membership.CustomField, id))} + end +end diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index bb919cb..0be4559 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -4,7 +4,6 @@ defmodule MvWeb.GlobalSettingsLive do ## Features - Edit the association/club name - - Manage custom fields - Real-time form validation - Success/error feedback @@ -29,7 +28,7 @@ defmodule MvWeb.GlobalSettingsLive do {:ok, socket - |> assign(:page_title, gettext("Settings")) + |> assign(:page_title, gettext("Club Settings")) |> assign(:settings, settings) |> assign_form()} end @@ -39,16 +38,12 @@ defmodule MvWeb.GlobalSettingsLive do ~H""" <.header> - {gettext("Settings")} + {gettext("Club Settings")} <:subtitle> {gettext("Manage global settings for the association.")} - <%!-- Club Settings Section --%> - <.header> - {gettext("Club Settings")} - <.form for={@form} id="settings-form" phx-change="validate" phx-submit="save"> <.input field={@form[:club_name]} @@ -61,12 +56,6 @@ defmodule MvWeb.GlobalSettingsLive do {gettext("Save Settings")} - - <%!-- Custom Fields Section --%> - <.live_component - module={MvWeb.CustomFieldLive.IndexComponent} - id="custom-fields-component" - /> """ end @@ -77,7 +66,6 @@ defmodule MvWeb.GlobalSettingsLive do assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))} end - @impl true def handle_event("save", %{"setting" => setting_params}, socket) do case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do {:ok, updated_settings} -> @@ -94,37 +82,6 @@ defmodule MvWeb.GlobalSettingsLive do end end - @impl true - def handle_info({:custom_field_saved, _custom_field, action}, socket) do - send_update(MvWeb.CustomFieldLive.IndexComponent, - id: "custom-fields-component", - show_form: false - ) - - {:noreply, - put_flash(socket, :info, gettext("Custom field %{action} successfully", action: action))} - end - - @impl true - def handle_info({:custom_field_deleted, _custom_field}, socket) do - {:noreply, put_flash(socket, :info, gettext("Custom field deleted successfully"))} - end - - @impl true - def handle_info({:custom_field_delete_error, error}, socket) do - {:noreply, - put_flash( - socket, - :error, - gettext("Failed to delete custom field: %{error}", error: inspect(error)) - )} - end - - @impl true - def handle_info(:custom_field_slug_mismatch, socket) do - {:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))} - end - defp assign_form(%{assigns: %{settings: settings}} = socket) do form = AshPhoenix.Form.for_update( diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 5380d0f..5370154 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -5,212 +5,80 @@ defmodule MvWeb.MemberLive.Form do ## Features - Create new members with personal information - Edit existing member details - - Grouped sections for better organization - - Tab navigation (Payments tab disabled, coming soon) - - Manage custom properties (dynamic fields, displayed sorted by name) + - Manage custom properties (dynamic fields) - Real-time validation with visual feedback + - Link/unlink user accounts - ## Form Sections - - Personal Data: Name, address, contact information, membership dates, notes - - Custom Fields: Dynamic fields in uniform grid layout (displayed sorted by name) - - Payment Data: Mockup section (not editable) + ## Form Fields + **Required:** + - first_name, last_name, email + + **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 - `validate` - Real-time form validation - `save` - Submit form (create or update member) + - Custom field value management events for adding/removing custom fields """ use MvWeb, :live_view @impl true def render(assigns) do - # Sort custom fields by name for display only - sorted_custom_fields = Enum.sort_by(assigns.custom_fields, & &1.name) - assigns = assign(assigns, :sorted_custom_fields, sorted_custom_fields) - ~H""" + <.header> + {@page_title} + <:subtitle> + {gettext("Fields marked with an asterisk (*) cannot be empty.")} + + + <.form for={@form} id="member-form" phx-change="validate" phx-submit="save"> - <%!-- Header with Back button, Name display, and Save button --%> -
- <.button navigate={return_path(@return_to, @member)} type="button"> - <.icon name="hero-arrow-left" class="size-4" /> - {gettext("Back")} - + <.input field={@form[:first_name]} label={gettext("First Name")} required /> + <.input field={@form[:last_name]} label={gettext("Last Name")} required /> + <.input field={@form[:email]} label={gettext("Email")} required type="email" /> + <.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" /> + <.input field={@form[:phone_number]} label={gettext("Phone Number")} /> + <.input field={@form[:join_date]} label={gettext("Join Date")} type="date" /> + <.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" /> + <.input field={@form[:notes]} label={gettext("Notes")} /> + <.input field={@form[:city]} label={gettext("City")} /> + <.input field={@form[:street]} label={gettext("Street")} /> + <.input field={@form[:house_number]} label={gettext("House Number")} /> + <.input field={@form[:postal_code]} label={gettext("Postal Code")} /> -

- <%= if @member do %> - {@member.first_name} {@member.last_name} - <% else %> - {gettext("New Member")} - <% end %> -

+

{gettext("Custom Field Values")}

+ <.inputs_for :let={f_custom_field_value} field={@form[:custom_field_values]}> + <% type = + Enum.find(@custom_fields, &(&1.id == f_custom_field_value[:custom_field_id].value)) %> + <.inputs_for :let={value_form} field={f_custom_field_value[:value]}> + <% input_type = + cond do + type && type.value_type == :boolean -> "checkbox" + type && type.value_type == :date -> :date + true -> :text + end %> + <.input field={value_form[:value]} label={type && type.name} type={input_type} /> + + + - <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> - {gettext("Save")} - -
- - <%!-- Tab Navigation --%> -
- - -
- - <%!-- Personal Data and Custom Fields Row --%> -
- <%!-- Personal Data Section --%> -
- <.form_section title={gettext("Personal Data")}> -
- <%!-- Name Row --%> -
-
- <.input field={@form[:first_name]} label={gettext("First Name")} required /> -
-
- <.input field={@form[:last_name]} label={gettext("Last Name")} required /> -
-
- - <%!-- Address Row --%> -
-
- <.input field={@form[:street]} label={gettext("Street")} /> -
-
- <.input field={@form[:house_number]} label={gettext("Nr.")} /> -
-
- <.input field={@form[:postal_code]} label={gettext("Postal Code")} /> -
-
- <.input field={@form[:city]} label={gettext("City")} /> -
-
- - <%!-- Email --%> -
- <.input field={@form[:email]} label={gettext("Email")} required type="email" /> -
- - <%!-- Phone --%> -
- <.input field={@form[:phone_number]} label={gettext("Phone")} type="tel" /> -
- - <%!-- Membership Dates Row --%> -
-
- <.input field={@form[:join_date]} label={gettext("Join Date")} type="date" /> -
-
- <.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" /> -
-
- - <%!-- Notes --%> -
- <.input field={@form[:notes]} label={gettext("Notes")} type="textarea" /> -
-
- -
- - <%!-- Custom Fields Section --%> - <%= if Enum.any?(@custom_fields) do %> -
- <.form_section title={gettext("Custom Fields")}> -
- <%!-- 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 %> -
- <.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)} - /> - - -
- <% end %> - - <% end %> -
- -
- <% end %> -
- - <%!-- Payment Data Section (Mockup) --%> -
- <.form_section title={gettext("Payment Data")}> - - -
-
- - -
-
- -
- - -
-
-
- <.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" /> -
-
- -
- - <%!-- Bottom Action Buttons --%> -
- <.button navigate={return_path(@return_to, @member)} type="button"> - {gettext("Cancel")} - - <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> - {gettext("Save Member")} - -
+ <.button phx-disable-with={gettext("Saving...")} variant="primary"> + {gettext("Save Member")} + + <.button navigate={return_path(@return_to, @member)}>{gettext("Cancel")}
""" @@ -238,8 +106,8 @@ defmodule MvWeb.MemberLive.Form do id -> Ash.get!(Mv.Membership.Member, id) end - page_title = - if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member") + action = if is_nil(member), do: "New", else: "Edit" + page_title = action <> " " <> "Member" {:ok, socket @@ -345,37 +213,5 @@ defmodule MvWeb.MemberLive.Form do end defp return_path("index", _member), do: ~p"/members" - defp return_path("show", nil), do: ~p"/members" defp return_path("show", member), do: ~p"/members/#{member.id}" - - # ----------------------------------------------------------------- - # Helper Components - # ----------------------------------------------------------------- - - # Renders a form section box with border and title. - attr :title, :string, required: true - slot :inner_block, required: true - - defp form_section(assigns) do - ~H""" -
-

{@title}

-
- {render_slot(@inner_block)} -
-
- """ - 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 diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index ad4a4a9..67ce522 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -32,12 +32,9 @@ defmodule MvWeb.MemberLive.Index do alias Mv.Membership alias MvWeb.MemberLive.Index.Formatter - alias MvWeb.Helpers.DateFormatter - alias MvWeb.MemberLive.Index.FieldSelection - alias MvWeb.MemberLive.Index.FieldVisibility # Prefix used in sort field names for custom fields (e.g., "custom_field_") - @custom_field_prefix Mv.Constants.custom_field_prefix() + @custom_field_prefix "custom_field_" # Member fields that are loaded for the overview # Uses constants from Mv.Constants to ensure consistency @@ -52,8 +49,8 @@ defmodule MvWeb.MemberLive.Index do payment filter, and member selection. Actual data loading happens in `handle_params/3`. """ @impl true - def mount(_params, session, socket) do - # Load custom fields that should be shown in overview (for display) + def mount(_params, _session, socket) do + # Load custom fields that should be shown in overview # Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView # and result in a 500 error page. This is appropriate for LiveViews where errors # should be visible to the user rather than silently failing. @@ -63,12 +60,6 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.sort(name: :asc) |> Ash.read!() - # Load ALL custom fields for the dropdown (to show all available fields) - all_custom_fields = - Mv.Membership.CustomField - |> Ash.Query.sort(name: :asc) - |> Ash.read!() - # Load settings once to avoid N+1 queries settings = case Membership.get_settings() do @@ -77,20 +68,6 @@ defmodule MvWeb.MemberLive.Index do {:error, _} -> %{member_field_visibility: %{}} end - # Load user field selection from session - session_selection = FieldSelection.get_from_session(session) - - # Get all available fields (for dropdown - includes ALL custom fields) - all_available_fields = FieldVisibility.get_all_available_fields(all_custom_fields) - - # Merge session selection with global settings for initial state (use all_custom_fields) - initial_selection = - FieldVisibility.merge_with_global_settings( - session_selection, - settings, - all_custom_fields - ) - socket = socket |> assign(:page_title, gettext("Members")) @@ -99,15 +76,8 @@ defmodule MvWeb.MemberLive.Index do |> assign_new(:sort_order, fn -> :asc end) |> assign(:paid_filter, nil) |> assign(:selected_members, MapSet.new()) - |> assign(:settings, settings) |> assign(:custom_fields_visible, custom_fields_visible) - |> assign(:all_custom_fields, all_custom_fields) - |> assign(:all_available_fields, all_available_fields) - |> assign(:user_field_selection, initial_selection) - |> assign( - :member_fields_visible, - FieldVisibility.get_visible_member_fields(initial_selection) - ) + |> assign(:member_fields_visible, get_visible_member_fields(settings)) # We call handle params to use the query from the URL {:ok, socket} @@ -212,8 +182,6 @@ defmodule MvWeb.MemberLive.Index do ## Supported messages: - `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL - `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL - - `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent - - `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent """ @impl true def handle_info({:sort, field_str}, socket) do @@ -282,111 +250,24 @@ defmodule MvWeb.MemberLive.Index do )} end - @impl true - def handle_info({:field_toggled, field_string, visible}, socket) do - # Update user field selection - new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible) - - # Save to session (cookie will be saved on next page load via handle_params) - socket = update_session_field_selection(socket, new_selection) - - # Merge with global settings (use all_custom_fields to allow enabling globally hidden fields) - final_selection = - FieldVisibility.merge_with_global_settings( - new_selection, - socket.assigns.settings, - socket.assigns.all_custom_fields - ) - - # Get visible fields - visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection) - visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) - - socket = - socket - |> assign(:user_field_selection, final_selection) - |> assign(:member_fields_visible, visible_member_fields) - |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) - |> load_members() - |> prepare_dynamic_cols() - |> push_field_selection_url() - - {:noreply, socket} - end - - @impl true - def handle_info({:fields_selected, selection}, socket) do - # Save to session - socket = update_session_field_selection(socket, selection) - - # Merge with global settings (use all_custom_fields for merging) - final_selection = - FieldVisibility.merge_with_global_settings( - selection, - socket.assigns.settings, - socket.assigns.all_custom_fields - ) - - # Get visible fields - visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection) - visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) - - socket = - socket - |> assign(:user_field_selection, final_selection) - |> assign(:member_fields_visible, visible_member_fields) - |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) - |> load_members() - |> prepare_dynamic_cols() - |> push_field_selection_url() - - {:noreply, socket} - end - # ----------------------------------------------------------------- # Handle Params from the URL # ----------------------------------------------------------------- @doc """ Handles URL parameter changes. - Parses query parameters for search query, sort field, sort order, and payment filter, and field selection, + Parses query parameters for search query, sort field, sort order, and payment filter, then loads members accordingly. This enables bookmarkable URLs and browser back/forward navigation. """ @impl true def handle_params(params, _url, socket) do - # Parse field selection from URL - url_selection = FieldSelection.parse_from_url(params) - - # Merge with session selection (URL has priority) - merged_selection = - FieldSelection.merge_sources( - url_selection, - socket.assigns.user_field_selection, - %{} - ) - - # Merge with global settings (use all_custom_fields for merging) - final_selection = - FieldVisibility.merge_with_global_settings( - merged_selection, - socket.assigns.settings, - socket.assigns.all_custom_fields - ) - - # Get visible fields - visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection) - visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) - socket = socket |> maybe_update_search(params) |> maybe_update_sort(params) |> maybe_update_paid_filter(params) |> assign(:query, params["query"]) - |> assign(:user_field_selection, final_selection) - |> assign(:member_fields_visible, visible_member_fields) - |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> load_members() |> prepare_dynamic_cols() @@ -399,17 +280,10 @@ defmodule MvWeb.MemberLive.Index do # - `:custom_field` - The CustomField resource # - `:render` - A function that formats the custom field value for a given member # - # Only includes custom fields that are visible according to user field selection. - # # Returns the socket with `:dynamic_cols` assigned. defp prepare_dynamic_cols(socket) do - visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] - - # Use all_custom_fields to allow users to enable globally hidden custom fields dynamic_cols = - socket.assigns.all_custom_fields - |> Enum.filter(fn custom_field -> custom_field.id in visible_custom_field_ids end) - |> Enum.map(fn custom_field -> + Enum.map(socket.assigns.custom_fields_visible, fn custom_field -> %{ custom_field: custom_field, render: fn member -> @@ -502,58 +376,6 @@ defmodule MvWeb.MemberLive.Index do )} end - # Builds query parameters including field selection - defp build_query_params(socket, base_params) do - # Use query from base_params if provided, otherwise fall back to socket.assigns.query - query_value = Map.get(base_params, "query") || socket.assigns.query || "" - - base_params - |> Map.put("query", query_value) - |> maybe_add_field_selection(socket.assigns[:user_field_selection]) - end - - # Adds field selection to query params if present - defp maybe_add_field_selection(params, nil), do: params - - defp maybe_add_field_selection(params, selection) when is_map(selection) do - fields_param = FieldSelection.to_url_param(selection) - if fields_param != "", do: Map.put(params, "fields", fields_param), else: params - end - - defp maybe_add_field_selection(params, _), do: params - - # Pushes URL with updated field selection - defp push_field_selection_url(socket) do - base_params = %{ - "sort_field" => field_to_string(socket.assigns.sort_field), - "sort_order" => Atom.to_string(socket.assigns.sort_order) - } - - # Include paid_filter if set - base_params = - case socket.assigns.paid_filter do - nil -> base_params - :paid -> Map.put(base_params, "paid_filter", "paid") - :not_paid -> Map.put(base_params, "paid_filter", "not_paid") - end - - query_params = build_query_params(socket, base_params) - new_path = ~p"/members?#{query_params}" - - push_patch(socket, to: new_path, replace: true) - end - - # Converts field to string - defp field_to_string(field) when is_atom(field), do: Atom.to_string(field) - defp field_to_string(field) when is_binary(field), do: field - - # Updates session field selection (stored in socket for now, actual session update via controller) - defp update_session_field_selection(socket, selection) do - # Store in socket for now - actual session persistence would require a controller - # This is a placeholder for future session persistence - assign(socket, :user_field_selection, selection) - end - # Builds URL query parameters map including all filter/sort state. # Converts paid_filter atom to string for URL. defp build_query_params(query, sort_field, sort_order, paid_filter) do @@ -612,9 +434,9 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.new() |> Ash.Query.select(@overview_fields) - # Load custom field values for visible custom fields (based on user selection) - visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] - query = load_custom_field_values(query, visible_custom_field_ids) + # Load custom field values for visible custom fields + custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id) + query = load_custom_field_values(query, custom_field_ids_list) # Apply the search filter first query = apply_search_filter(query, search_query) @@ -792,18 +614,6 @@ defmodule MvWeb.MemberLive.Index do defp extract_custom_field_id(_), do: nil - # Extracts custom field IDs from visible custom field strings - # Format: "custom_field_" -> - defp extract_custom_field_ids(visible_custom_fields) do - Enum.map(visible_custom_fields, fn field_string -> - case String.split(field_string, @custom_field_prefix) do - ["", id] -> id - _ -> nil - end - end) - |> Enum.filter(&(&1 != nil)) - end - # Sorts members in memory by a custom field value. # # Process: @@ -1100,6 +910,31 @@ defmodule MvWeb.MemberLive.Index do end end - # Public helper function to format dates for use in templates - def format_date(date), do: DateFormatter.format_date(date) + # Gets the list of member fields that should be visible in the overview. + # + # Reads the visibility configuration from Settings and returns only the fields + # where show_in_overview is true. Fields not configured in settings default to true. + # + # Performance: This function uses the already-loaded settings to avoid N+1 queries. + # Settings should be loaded once in mount/3 and passed to this function. + # + # Parameters: + # - `settings` - The settings struct loaded from the database + # + # Returns a list of atoms representing visible member field names. + # + # Fields are read from the global Constants module. + @spec get_visible_member_fields(map()) :: [atom()] + defp get_visible_member_fields(settings) do + # Get all eligible fields from the global constants + all_fields = Mv.Constants.member_fields() + + # JSONB stores keys as strings + visibility_config = settings.member_field_visibility || %{} + + # Filter to only return visible fields + Enum.filter(all_fields, fn field -> + Map.get(visibility_config, Atom.to_string(field), true) + end) + end end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 1658209..9f8851b 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -44,13 +44,6 @@ paid_filter={@paid_filter} member_count={length(@members)} /> - <.live_component - module={MvWeb.Components.FieldVisibilityDropdownComponent} - id="field-visibility-dropdown" - all_fields={@all_available_fields} - custom_fields={@all_custom_fields} - selected_fields={@user_field_selection} - />
<.table @@ -92,7 +85,6 @@ <:col :let={member} - :if={:first_name in @member_fields_visible} label={ ~H""" <.live_component @@ -106,25 +98,7 @@ """ } > - {member.first_name} - - <:col - :let={member} - :if={:last_name in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_last_name} - field={:last_name} - label={gettext("Last name")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.last_name} + {member.first_name} {member.last_name} <:col :let={member} @@ -250,9 +224,9 @@ """ } > - {MvWeb.MemberLive.Index.format_date(member.join_date)} + {member.join_date} - <:col :let={member} :if={:paid in @member_fields_visible} label={gettext("Paid")}> + <:col :let={member} label={gettext("Paid")}> Session > Cookie - - ## Data Format - - Field selection is stored as a map: - ```elixir - %{ - "first_name" => true, - "email" => true, - "street" => false, - "custom_field_abc-123" => true - } - ``` - - ## Cookie/Session Format - - Stored as JSON string: `{"first_name":true,"email":true}` - - ## URL Format - - Comma-separated list: `?fields=first_name,email,custom_field_abc-123` - """ - - @cookie_name "member_field_selection" - @cookie_max_age 365 * 24 * 60 * 60 - @session_key "member_field_selection" - - @doc """ - Reads field selection from session. - - Returns a map of field names (strings) to boolean visibility values. - Returns empty map if no selection is stored. - """ - @spec get_from_session(map()) :: %{String.t() => boolean()} - def get_from_session(session) when is_map(session) do - case Map.get(session, @session_key) do - nil -> %{} - json_string when is_binary(json_string) -> parse_json(json_string) - _ -> %{} - end - end - - def get_from_session(_), do: %{} - - @doc """ - Saves field selection to session. - - Converts the map to JSON string and stores it in the session. - """ - @spec save_to_session(map(), %{String.t() => boolean()}) :: map() - def save_to_session(session, selection) when is_map(selection) do - json_string = Jason.encode!(selection) - Map.put(session, @session_key, json_string) - end - - def save_to_session(session, _), do: session - - @doc """ - Reads field selection from cookie. - - Returns a map of field names (strings) to boolean visibility values. - Returns empty map if no cookie is present. - - Note: This function parses the raw Cookie header. In LiveView, cookies - are typically accessed via get_connect_info. - """ - @spec get_from_cookie(Plug.Conn.t()) :: %{String.t() => boolean()} - def get_from_cookie(conn) do - # get_req_header always returns a list ([] if no header, [value] if present) - case Plug.Conn.get_req_header(conn, "cookie") do - [] -> - %{} - - [cookie_header | _rest] -> - cookies = parse_cookie_header(cookie_header) - - case Map.get(cookies, @cookie_name) do - nil -> %{} - json_string when is_binary(json_string) -> parse_json(json_string) - _ -> %{} - end - end - end - - # Parses cookie header string into a map - defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do - cookie_header - |> String.split(";") - |> Enum.map(&String.trim/1) - |> Enum.map(&String.split(&1, "=", parts: 2)) - |> Enum.reduce(%{}, fn - [key, value], acc -> Map.put(acc, key, URI.decode(value)) - [key], acc -> Map.put(acc, key, "") - _, acc -> acc - end) - end - - defp parse_cookie_header(_), do: %{} - - @doc """ - Saves field selection to cookie. - - Sets a persistent cookie with the field selection as JSON. - """ - @spec save_to_cookie(Plug.Conn.t(), %{String.t() => boolean()}) :: Plug.Conn.t() - def save_to_cookie(conn, selection) when is_map(selection) do - json_string = Jason.encode!(selection) - secure = Application.get_env(:mv, :use_secure_cookies, false) - - Plug.Conn.put_resp_cookie(conn, @cookie_name, json_string, - max_age: @cookie_max_age, - same_site: "Lax", - http_only: true, - secure: secure - ) - end - - def save_to_cookie(conn, _), do: conn - - @doc """ - Parses field selection from URL parameters. - - Expects a comma-separated list of field names in the `fields` parameter. - All fields in the list are set to `true` (visible). - - ## Examples - - iex> parse_from_url(%{"fields" => "first_name,email"}) - %{"first_name" => true, "email" => true} - - iex> parse_from_url(%{"fields" => "custom_field_abc-123"}) - %{"custom_field_abc-123" => true} - - iex> parse_from_url(%{}) - %{} - """ - @spec parse_from_url(map()) :: %{String.t() => boolean()} - def parse_from_url(params) when is_map(params) do - case Map.get(params, "fields") do - nil -> %{} - "" -> %{} - fields_string when is_binary(fields_string) -> parse_fields_string(fields_string) - _ -> %{} - end - end - - def parse_from_url(_), do: %{} - - @doc """ - Merges multiple field selection sources with priority. - - Priority order (highest to lowest): - 1. URL parameters - 2. Session - 3. Cookie - - Later sources override earlier ones for the same field. - - ## Examples - - iex> merge_sources(%{"first_name" => true}, %{"email" => true}, %{"street" => true}) - %{"first_name" => true, "email" => true, "street" => true} - - iex> merge_sources(%{"first_name" => false}, %{"first_name" => true}, %{}) - %{"first_name" => false} # URL has priority - """ - @spec merge_sources( - %{String.t() => boolean()}, - %{String.t() => boolean()}, - %{String.t() => boolean()} - ) :: %{String.t() => boolean()} - def merge_sources(url_selection, session_selection, cookie_selection) do - %{} - |> Map.merge(cookie_selection) - |> Map.merge(session_selection) - |> Map.merge(url_selection) - end - - @doc """ - Converts field selection map to URL parameter string. - - Returns a comma-separated string of visible fields (where value is `true`). - - ## Examples - - iex> to_url_param(%{"first_name" => true, "email" => true, "street" => false}) - "first_name,email" - """ - @spec to_url_param(%{String.t() => boolean()}) :: String.t() - def to_url_param(selection) when is_map(selection) do - selection - |> Enum.filter(fn {_field, visible} -> visible end) - |> Enum.map_join(",", fn {field, _visible} -> field end) - end - - def to_url_param(_), do: "" - - # Parses a JSON string into a map, handling errors gracefully - defp parse_json(json_string) when is_binary(json_string) do - case Jason.decode(json_string) do - {:ok, decoded} when is_map(decoded) -> - # Ensure all values are booleans - Enum.reduce(decoded, %{}, fn - {key, value}, acc when is_boolean(value) -> Map.put(acc, key, value) - {key, _value}, acc -> Map.put(acc, key, true) - end) - - _ -> - %{} - end - end - - defp parse_json(_), do: %{} - - # Parses a comma-separated string of field names - defp parse_fields_string(fields_string) do - fields_string - |> String.split(",") - |> Enum.map(&String.trim/1) - |> Enum.filter(&(&1 != "")) - |> Enum.reduce(%{}, fn field, acc -> Map.put(acc, field, true) end) - end -end diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex deleted file mode 100644 index c9c8bd6..0000000 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ /dev/null @@ -1,239 +0,0 @@ -defmodule MvWeb.MemberLive.Index.FieldVisibility do - @moduledoc """ - Manages field visibility by merging user-specific selection with global settings. - - This module handles: - - Getting all available fields (member fields + custom fields) - - Merging user selection with global settings (user selection takes priority) - - Falling back to global settings when no user selection exists - - Converting between different field name formats (atoms vs strings) - - ## Field Naming Convention - - - **Member Fields**: Atoms (e.g., `:first_name`, `:email`) - - **Custom Fields**: Strings with format `"custom_field_"` (e.g., `"custom_field_abc-123"`) - - ## Priority Order - - 1. User-specific selection (from URL/Session/Cookie) - 2. Global settings (from database) - 3. Default (all fields visible) - """ - - @doc """ - Gets all available fields for selection. - - Returns a list of field identifiers: - - Member fields as atoms (e.g., `:first_name`, `:email`) - - Custom fields as strings (e.g., `"custom_field_abc-123"`) - - ## Parameters - - - `custom_fields` - List of CustomField resources that are available - - ## Returns - - List of field identifiers (atoms and strings) - """ - @spec get_all_available_fields([struct()]) :: [atom() | String.t()] - def get_all_available_fields(custom_fields) do - member_fields = Mv.Constants.member_fields() - custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}") - - member_fields ++ custom_field_names - end - - @doc """ - Merges user field selection with global settings. - - User selection takes priority over global settings. If a field is not in the - user selection, the global setting is used. If a field is not in global settings, - it defaults to `true` (visible). - - ## Parameters - - - `user_selection` - Map of field names (strings) to boolean visibility - - `global_settings` - Settings struct with `member_field_visibility` field - - `custom_fields` - List of CustomField resources - - ## Returns - - Map of field names (strings) to boolean visibility values - - ## Examples - - iex> user_selection = %{"first_name" => false} - iex> settings = %{member_field_visibility: %{first_name: true, email: true}} - iex> merge_with_global_settings(user_selection, settings, []) - %{"first_name" => false, "email" => true} # User selection overrides global - """ - @spec merge_with_global_settings( - %{String.t() => boolean()}, - map(), - [struct()] - ) :: %{String.t() => boolean()} - def merge_with_global_settings(user_selection, global_settings, custom_fields) do - all_fields = get_all_available_fields(custom_fields) - global_visibility = get_global_visibility_map(global_settings, custom_fields) - - Enum.reduce(all_fields, %{}, fn field, acc -> - field_string = field_to_string(field) - - visibility = - case Map.get(user_selection, field_string) do - nil -> Map.get(global_visibility, field_string, true) - user_value -> user_value - end - - Map.put(acc, field_string, visibility) - end) - end - - @doc """ - Gets the list of visible fields from a field selection map. - - Returns only fields where visibility is `true`. - - ## Parameters - - - `field_selection` - Map of field names to boolean visibility - - ## Returns - - List of field identifiers (atoms for member fields, strings for custom fields) - - ## Examples - - iex> selection = %{"first_name" => true, "email" => false, "street" => true} - iex> get_visible_fields(selection) - [:first_name, :street] - """ - @spec get_visible_fields(%{String.t() => boolean()}) :: [atom() | String.t()] - def get_visible_fields(field_selection) when is_map(field_selection) do - field_selection - |> Enum.filter(fn {_field, visible} -> visible end) - |> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end) - end - - def get_visible_fields(_), do: [] - - @doc """ - Gets visible member fields from field selection. - - Returns only member fields (atoms) that are visible. - - ## Examples - - iex> selection = %{"first_name" => true, "email" => true, "custom_field_123" => true} - iex> get_visible_member_fields(selection) - [:first_name, :email] - """ - @spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()] - def get_visible_member_fields(field_selection) when is_map(field_selection) do - member_fields = Mv.Constants.member_fields() - - field_selection - |> Enum.filter(fn {field_string, visible} -> - field_atom = to_field_identifier(field_string) - visible && field_atom in member_fields - end) - |> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end) - end - - def get_visible_member_fields(_), do: [] - - @doc """ - Gets visible custom fields from field selection. - - Returns only custom field identifiers (strings) that are visible. - - ## Examples - - iex> selection = %{"first_name" => true, "custom_field_123" => true, "custom_field_456" => false} - iex> get_visible_custom_fields(selection) - ["custom_field_123"] - """ - @spec get_visible_custom_fields(%{String.t() => boolean()}) :: [String.t()] - def get_visible_custom_fields(field_selection) when is_map(field_selection) do - prefix = Mv.Constants.custom_field_prefix() - - field_selection - |> Enum.filter(fn {field_string, visible} -> - visible && String.starts_with?(field_string, prefix) - end) - |> Enum.map(fn {field_string, _visible} -> field_string end) - end - - def get_visible_custom_fields(_), do: [] - - # Gets global visibility map from settings - defp get_global_visibility_map(settings, custom_fields) do - member_visibility = get_member_field_visibility_from_settings(settings) - custom_field_visibility = get_custom_field_visibility(custom_fields) - - Map.merge(member_visibility, custom_field_visibility) - end - - # Gets member field visibility from settings - defp get_member_field_visibility_from_settings(settings) do - visibility_config = - normalize_visibility_config(Map.get(settings, :member_field_visibility, %{})) - - member_fields = Mv.Constants.member_fields() - - Enum.reduce(member_fields, %{}, fn field, acc -> - field_string = Atom.to_string(field) - show_in_overview = Map.get(visibility_config, field, true) - Map.put(acc, field_string, show_in_overview) - end) - end - - # Gets custom field visibility (all custom fields with show_in_overview=true are visible) - defp get_custom_field_visibility(custom_fields) do - prefix = Mv.Constants.custom_field_prefix() - - Enum.reduce(custom_fields, %{}, fn custom_field, acc -> - field_string = "#{prefix}#{custom_field.id}" - visible = Map.get(custom_field, :show_in_overview, true) - Map.put(acc, field_string, visible) - end) - end - - # Normalizes visibility config map keys from strings to atoms - defp normalize_visibility_config(config) when is_map(config) do - Enum.reduce(config, %{}, fn - {key, value}, acc when is_atom(key) -> - Map.put(acc, key, value) - - {key, value}, acc when is_binary(key) -> - try do - atom_key = String.to_existing_atom(key) - Map.put(acc, atom_key, value) - rescue - ArgumentError -> acc - end - - _, acc -> - acc - end) - end - - defp normalize_visibility_config(_), do: %{} - - # Converts field string to atom (for member fields) or keeps as string (for custom fields) - defp to_field_identifier(field_string) when is_binary(field_string) do - if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do - field_string - else - try do - String.to_existing_atom(field_string) - rescue - ArgumentError -> field_string - end - end - end - - # Converts field identifier to string - defp field_to_string(field) when is_atom(field), do: Atom.to_string(field) - defp field_to_string(field) when is_binary(field), do: field -end diff --git a/lib/mv_web/live/member_live/index/formatter.ex b/lib/mv_web/live/member_live/index/formatter.ex index a4bfff2..2074962 100644 --- a/lib/mv_web/live/member_live/index/formatter.ex +++ b/lib/mv_web/live/member_live/index/formatter.ex @@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.Index.Formatter do formats them appropriately for display in the UI. """ use Gettext, backend: MvWeb.Gettext - alias MvWeb.Helpers.DateFormatter @doc """ Formats a custom field value for display. @@ -62,11 +61,11 @@ defmodule MvWeb.MemberLive.Index.Formatter do defp format_value_by_type(value, :boolean, _) when value == false, do: gettext("No") defp format_value_by_type(value, :boolean, _), do: to_string(value) - defp format_value_by_type(%Date{} = date, :date, _), do: DateFormatter.format_date(date) + defp format_value_by_type(%Date{} = date, :date, _), do: Date.to_string(date) defp format_value_by_type(value, :date, _) when is_binary(value) do case Date.from_iso8601(value) do - {:ok, date} -> DateFormatter.format_date(date) + {:ok, date} -> Date.to_string(date) _ -> value end end diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index d84fca4..de46a3a 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -3,16 +3,19 @@ defmodule MvWeb.MemberLive.Show do LiveView for displaying a single member's details. ## Features - - Display all member information in grouped sections - - Tab navigation for future features (Payments) - - Show custom field values with type-based formatting + - Display all member information (personal, contact, address) + - Show linked user account (if exists) + - Display custom field values - Navigate to edit form - Return to member list - ## Sections - - Personal Data: Name, address, contact information, membership dates, notes - - Custom Fields: Dynamic fields in uniform grid layout (sorted by name) - - Payment Data: Mockup section with placeholder data + ## Displayed Information + - Basic: name, email, dates (join, exit) + - Contact: phone number + - Address: street, house number, postal code, city + - Status: paid flag + - Relationships: linked user account + - Custom: dynamic custom field values from CustomFields ## Navigation - Back to member list @@ -25,150 +28,66 @@ defmodule MvWeb.MemberLive.Show do def render(assigns) do ~H""" - <%!-- Header with Back button, Name, and Edit button --%> -
- <.button navigate={~p"/members"} aria-label={gettext("Back to members list")}> - <.icon name="hero-arrow-left" class="size-4" /> - {gettext("Back")} - + <.header> + {@member.first_name} {@member.last_name} + <:subtitle>{gettext("This is a member record from your database.")} -

- {@member.first_name} {@member.last_name} -

+ <:actions> + <.button navigate={~p"/members"} aria-label={gettext("Back to members list")}> + <.icon name="hero-arrow-left" /> + {gettext("Back to members list")} + + <.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}> + <.icon name="hero-pencil-square" /> {gettext("Edit Member")} + + + - <.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}> - {gettext("Edit Member")} - -
+ <.list> + <:item title={gettext("Id")}>{@member.id} + <:item title={gettext("First Name")}>{@member.first_name} + <:item title={gettext("Last Name")}>{@member.last_name} + <:item title={gettext("Email")}>{@member.email} + <:item title={gettext("Paid")}> + {if @member.paid, do: gettext("Yes"), else: gettext("No")} + + <:item title={gettext("Phone Number")}>{@member.phone_number} + <:item title={gettext("Join Date")}>{@member.join_date} + <:item title={gettext("Exit Date")}>{@member.exit_date} + <:item title={gettext("Notes")}>{@member.notes} + <:item title={gettext("City")}>{@member.city} + <:item title={gettext("Street")}>{@member.street} + <:item title={gettext("House Number")}>{@member.house_number} + <:item title={gettext("Postal Code")}>{@member.postal_code} + <: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} + + <% else %> + {gettext("No user linked")} + <% end %> + + - <%!-- Tab Navigation --%> -
- - -
- - <%!-- Personal Data and Custom Fields Row --%> -
- <%!-- Personal Data Section --%> -
- <.section_box title={gettext("Personal Data")}> -
- <%!-- Name Row --%> -
- <.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" /> -
- - <%!-- Address --%> -
- <.data_field label={gettext("Address")} value={format_address(@member)} /> -
- - <%!-- Email --%> -
- <.data_field label={gettext("Email")}> - - {@member.email} - - -
- - <%!-- Phone --%> -
- <.data_field label={gettext("Phone")} value={@member.phone_number} /> -
- - <%!-- Membership Dates Row --%> -
- <.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" - /> -
- - <%!-- Linked User --%> -
- <.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} - - <% else %> - {gettext("No user linked")} - <% end %> - -
- - <%!-- Notes --%> - <%= if @member.notes && String.trim(@member.notes) != "" do %> -
- <.data_field label={gettext("Notes")}> -

{@member.notes}

- -
- <% end %> -
- -
- - <%!-- Custom Fields Section --%> - <%= if Enum.any?(@member.custom_field_values) do %> -
- <.section_box title={gettext("Custom Fields")}> -
- <%= 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)} - - <% end %> -
- -
- <% end %> -
- - <%!-- Payment Data Section (Mockup) --%> -
- <.section_box title={gettext("Payment Data")}> - - -
- <.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 %> - {gettext("Paid")} - <% else %> - {gettext("Pending")} - <% end %> - -
- -
+

{gettext("Custom Field Values")}

+ <.generic_list items={ + Enum.map(@member.custom_field_values, fn cfv -> + { + # name + cfv.custom_field && cfv.custom_field.name, + # value + case cfv.value do + %{value: v} -> v + v -> v + end + } + end) + } />
""" end @@ -195,120 +114,4 @@ defmodule MvWeb.MemberLive.Show do defp page_title(:show), do: gettext("Show Member") defp page_title(:edit), do: gettext("Edit Member") - - # ----------------------------------------------------------------- - # Helper Components - # ----------------------------------------------------------------- - - # Renders a section box with border and title. - attr :title, :string, required: true - slot :inner_block, required: true - - defp section_box(assigns) do - ~H""" -
-

{@title}

-
- {render_slot(@inner_block)} -
-
- """ - 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""" -
-
{@label}
-
- <%= if @inner_block != [] do %> - {render_slot(@inner_block)} - <% else %> - {display_value(@value)} - <% end %> -
-
- """ - 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""" - {@email} - """ - 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 diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 0639e75..9619a15 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -42,7 +42,7 @@ defmodule MvWeb.UserLive.Form do <:subtitle>{gettext("Use this form to manage user records in your database.")} - <.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save"> + <.form for={@form} id="user-form" phx-change="validate" phx-submit="save"> <.input field={@form[:email]} label={gettext("Email")} required type="email" /> @@ -61,7 +61,7 @@ defmodule MvWeb.UserLive.Form do <%= if @show_password_fields do %> -
+
<.input field={@form[:password]} label={gettext("Password")} @@ -83,7 +83,7 @@ defmodule MvWeb.UserLive.Form do

{gettext("Password requirements")}:

-
    +
    • {gettext("At least 8 characters")}
    • {gettext("Include both letters and numbers")}
    • {gettext("Consider using special characters")}
    • @@ -91,7 +91,7 @@ defmodule MvWeb.UserLive.Form do
<%= if @user do %> -
+

{gettext("Admin Note")}: {gettext( "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system." @@ -102,7 +102,7 @@ defmodule MvWeb.UserLive.Form do

<% else %> <%= if @user do %> -
+

{gettext("Note")}: {gettext( "Check 'Change Password' above to set a new password for this user." @@ -110,7 +110,7 @@ defmodule MvWeb.UserLive.Form do

<% else %> -
+

{gettext("Note")}: {gettext( "User will be created without a password. Check 'Set Password' to add one." @@ -123,11 +123,11 @@ defmodule MvWeb.UserLive.Form do

-

{gettext("Linked Member")}

+

{gettext("Linked Member")}

<%= if @user && @user.member && !@unlink_member do %> -
+

@@ -147,7 +147,7 @@ defmodule MvWeb.UserLive.Form do <% else %> <%= if @unlink_member do %> -

+

{gettext("Unlinking scheduled")}: {gettext( "Member will be unlinked when you save. Cannot select new member until saved." @@ -219,7 +219,7 @@ defmodule MvWeb.UserLive.Form do

<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %> -
+

{gettext("Note")}: {gettext( "A member with this email already exists. To link with a different member, please change one of the email addresses first." @@ -231,12 +231,12 @@ defmodule MvWeb.UserLive.Form do <%= if @selected_member_id && @selected_member_name do %>

{gettext("Selected")}: {@selected_member_name}

-

+

{gettext("Save to confirm linking.")}

@@ -245,12 +245,10 @@ defmodule MvWeb.UserLive.Form do <% end %>
-
- <.button phx-disable-with={gettext("Saving...")} variant="primary"> - {gettext("Save User")} - - <.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")} -
+ <.button phx-disable-with={gettext("Saving...")} variant="primary"> + {gettext("Save User")} + + <.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")} """ diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex index 9a98159..3582046 100644 --- a/lib/mv_web/live/user_live/index.html.heex +++ b/lib/mv_web/live/user_live/index.html.heex @@ -49,6 +49,7 @@ > {user.email} + <:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id} <:col :let={user} label={gettext("Linked Member")}> <%= if user.member do %> {user.member.first_name} {user.member.last_name} diff --git a/lib/mv_web/live/user_live/show.ex b/lib/mv_web/live/user_live/show.ex index 777def1..664f99f 100644 --- a/lib/mv_web/live/user_live/show.ex +++ b/lib/mv_web/live/user_live/show.ex @@ -46,7 +46,9 @@ defmodule MvWeb.UserLive.Show do <.list> + <:item title={gettext("ID")}>{@user.id} <:item title={gettext("Email")}>{@user.email} + <:item title={gettext("OIDC ID")}>{@user.oidc_id || gettext("Not set")} <:item title={gettext("Password Authentication")}> {if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")} @@ -54,13 +56,13 @@ defmodule MvWeb.UserLive.Show do <%= if @user.member do %> <.link navigate={~p"/members/#{@user.member}"} - class="text-blue-600 underline hover:text-blue-800" + class="text-blue-600 hover:text-blue-800 underline" > - <.icon name="hero-users" class="inline w-4 h-4 mr-1" /> + <.icon name="hero-users" class="h-4 w-4 inline mr-1" /> {@user.member.first_name} {@user.member.last_name} <% else %> - {gettext("No member linked")} + {gettext("No member linked")} <% end %> diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index d6f108e..09a2792 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -55,6 +55,12 @@ defmodule MvWeb.Router do live "/members/:id", MemberLive.Show, :show live "/members/:id/show/edit", MemberLive.Show, :edit + live "/custom_fields", CustomFieldLive.Index, :index + live "/custom_fields/new", CustomFieldLive.Form, :new + live "/custom_fields/:id/edit", CustomFieldLive.Form, :edit + live "/custom_fields/:id", CustomFieldLive.Show, :show + live "/custom_fields/:id/show/edit", CustomFieldLive.Show, :edit + live "/custom_field_values", CustomFieldValueLive.Index, :index live "/custom_field_values/new", CustomFieldValueLive.Form, :new live "/custom_field_values/:id/edit", CustomFieldValueLive.Form, :edit @@ -69,11 +75,6 @@ defmodule MvWeb.Router do live "/settings", GlobalSettingsLive - # Contribution Management (Mock-ups) - live "/contribution_types", ContributionTypeLive.Index, :index - live "/contribution_settings", ContributionSettingsLive - live "/contributions/member/:id", ContributionPeriodLive.Show, :show - post "/set_locale", LocaleController, :set_locale end diff --git a/mix.exs b/mix.exs index 7a13ab0..c6e4fb5 100644 --- a/mix.exs +++ b/mix.exs @@ -12,8 +12,7 @@ defmodule Mv.MixProject do compilers: [:phoenix_live_view] ++ Mix.compilers(), aliases: aliases(), deps: deps(), - listeners: [Phoenix.CodeReloader], - gettext: [write_reference_line_numbers: false] + listeners: [Phoenix.CodeReloader] ] end diff --git a/priv/gettext/auth.pot b/priv/gettext/auth.pot index 1cc60cb..ebb8d3c 100644 --- a/priv/gettext/auth.pot +++ b/priv/gettext/auth.pot @@ -36,7 +36,7 @@ msgstr "" msgid "Need an account?" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:268 #, elixir-autogen msgid "Password" msgstr "" @@ -65,77 +65,78 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:254 #, elixir-autogen, elixir-format msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:289 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:163 #, elixir-autogen, elixir-format msgid "Incorrect password. Please try again." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:37 #, elixir-autogen, elixir-format msgid "Invalid session. Please try again." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:281 #, elixir-autogen, elixir-format msgid "Link Account" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:252 #, elixir-autogen, elixir-format msgid "Link OIDC Account" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:280 #, elixir-autogen, elixir-format msgid "Linking..." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:40 #, elixir-autogen, elixir-format msgid "Session expired. Please try again." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:209 #, elixir-autogen, elixir-format msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:76 #, elixir-autogen, elixir-format msgid "Account activated! Redirecting to complete sign-in..." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:119 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:123 #, elixir-autogen, elixir-format msgid "Failed to link account. Please try again or contact support." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:108 #, elixir-autogen, elixir-format msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:98 #, elixir-autogen, elixir-format msgid "This OIDC account is already linked to another user. Please contact support." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:235 #, elixir-autogen, elixir-format msgid "Language selection" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:242 #, elixir-autogen, elixir-format msgid "Select language" msgstr "" diff --git a/priv/gettext/de/LC_MESSAGES/auth.po b/priv/gettext/de/LC_MESSAGES/auth.po index cdcc9ff..f0cbdf3 100644 --- a/priv/gettext/de/LC_MESSAGES/auth.po +++ b/priv/gettext/de/LC_MESSAGES/auth.po @@ -35,7 +35,7 @@ msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit einer A msgid "Need an account?" msgstr "Konto anlegen?" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:268 #, elixir-autogen msgid "Password" msgstr "Passwort" @@ -64,77 +64,78 @@ msgstr "Anmelden..." msgid "Your password has successfully been reset" msgstr "Das Passwort wurde erfolgreich zurückgesetzt" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:254 #, elixir-autogen, elixir-format msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account." msgstr "Ein Konto mit der E-Mail %{email} existiert bereits. Bitte geben Sie Ihr Passwort ein, um Ihr OIDC-Konto zu verknüpfen." -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:289 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "Abbrechen" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:163 #, elixir-autogen, elixir-format msgid "Incorrect password. Please try again." msgstr "Falsches Passwort. Bitte versuchen Sie es erneut." -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:37 #, elixir-autogen, elixir-format msgid "Invalid session. Please try again." msgstr "Ungültige Sitzung. Bitte versuchen Sie es erneut." -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:281 #, elixir-autogen, elixir-format msgid "Link Account" msgstr "Konto verknüpfen" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:252 #, elixir-autogen, elixir-format msgid "Link OIDC Account" msgstr "OIDC-Konto verknüpfen" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:280 #, elixir-autogen, elixir-format msgid "Linking..." msgstr "Verknüpfen..." -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:40 #, elixir-autogen, elixir-format msgid "Session expired. Please try again." msgstr "Sitzung abgelaufen. Bitte versuchen Sie es erneut." -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:209 #, elixir-autogen, elixir-format msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." msgstr "Ihr OIDC-Konto wurde erfolgreich verknüpft! Sie werden zur Anmeldung weitergeleitet..." -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:76 #, elixir-autogen, elixir-format msgid "Account activated! Redirecting to complete sign-in..." msgstr "Konto aktiviert! Sie werden zur Anmeldung weitergeleitet..." -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:119 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:123 #, elixir-autogen, elixir-format msgid "Failed to link account. Please try again or contact support." msgstr "Verknüpfung des Kontos fehlgeschlagen. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support." -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:108 #, elixir-autogen, elixir-format msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support." msgstr "Die E-Mail-Adresse aus Ihrem OIDC-Provider ist bereits für ein anderes Konto registriert. Bitte Àndern Sie Ihre E-Mail-Adresse im Identity-Provider oder kontaktieren Sie den Support." -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:98 #, elixir-autogen, elixir-format msgid "This OIDC account is already linked to another user. Please contact support." msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktieren Sie den Support." -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:235 #, elixir-autogen, elixir-format msgid "Language selection" msgstr "Sprachauswahl" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:242 #, elixir-autogen, elixir-format msgid "Select language" msgstr "Sprache auswÀhlen" diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index bb781f7..57df5ab 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -10,1452 +10,851 @@ msgid "" msgstr "" "Language: en\n" -#: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/contribution_period_live/show.ex +#: lib/mv_web/components/core_components.ex:386 #, elixir-autogen, elixir-format msgid "Actions" msgstr "Aktionen" -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:248 +#: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "Bist du sicher?" -#: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts.ex:82 +#: lib/mv_web/components/layouts.ex:94 #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "Verbindung wird wiederhergestellt" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/form.ex:53 +#: lib/mv_web/live/member_live/index.html.heex:184 +#: lib/mv_web/live/member_live/show.ex:58 #, elixir-autogen, elixir-format msgid "City" msgstr "Stadt" -#: lib/mv_web/live/contribution_type_live/index.ex -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:250 +#: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "Lâschen" -#: lib/mv_web/live/contribution_type_live/index.ex -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:242 +#: lib/mv_web/live/user_live/form.ex:265 +#: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format msgid "Edit" msgstr "Bearbeite" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:41 +#: lib/mv_web/live/member_live/show.ex:116 #, elixir-autogen, elixir-format msgid "Edit Member" msgstr "Mitglied bearbeiten" -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/index.html.heex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/member_live/form.ex:47 +#: lib/mv_web/live/member_live/index.html.heex:112 +#: lib/mv_web/live/member_live/show.ex:50 +#: lib/mv_web/live/user_live/form.ex:46 +#: lib/mv_web/live/user_live/index.html.heex:44 +#: lib/mv_web/live/user_live/show.ex:50 #, elixir-autogen, elixir-format msgid "Email" msgstr "E-Mail" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/form.ex:45 +#: lib/mv_web/live/member_live/show.ex:48 #, elixir-autogen, elixir-format msgid "First Name" msgstr "Vorname" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/form.ex:50 +#: lib/mv_web/live/member_live/index.html.heex:220 +#: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Join Date" msgstr "Beitrittsdatum" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/form.ex:46 +#: lib/mv_web/live/member_live/show.ex:49 #, elixir-autogen, elixir-format msgid "Last Name" msgstr "Nachname" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:29 #, elixir-autogen, elixir-format msgid "New Member" msgstr "Neues Mitglied" -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:239 +#: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" msgstr "Anzeigen" -#: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts.ex:89 #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "Etwas ist schiefgelaufen!" -#: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts.ex:77 #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "Keine Internetverbindung gefunden" -#: lib/mv_web/components/core_components.ex +#: lib/mv_web/components/core_components.ex:82 #, elixir-autogen, elixir-format msgid "close" msgstr "schließen" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/form.ex:51 +#: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Exit Date" msgstr "Austrittsdatum" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/form.ex:55 +#: lib/mv_web/live/member_live/index.html.heex:148 +#: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "House Number" msgstr "Hausnummer" -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/form.ex:52 +#: lib/mv_web/live/member_live/show.ex:57 #, elixir-autogen, elixir-format msgid "Notes" msgstr "Notizen" -#: lib/mv_web/live/components/payment_filter_component.ex -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/components/payment_filter_component.ex:94 +#: lib/mv_web/live/components/payment_filter_component.ex:144 +#: lib/mv_web/live/member_live/form.ex:48 +#: lib/mv_web/live/member_live/index.html.heex:229 +#: lib/mv_web/live/member_live/show.ex:51 #, elixir-autogen, elixir-format msgid "Paid" msgstr "Bezahlt" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/form.ex:49 +#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/member_live/show.ex:54 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "Telefonnummer" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/form.ex:56 +#: lib/mv_web/live/member_live/index.html.heex:166 +#: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "Postal Code" msgstr "Postleitzahl" -#: lib/mv_web/live/member_live/form.ex +#: lib/mv_web/live/member_live/form.ex:79 #, elixir-autogen, elixir-format msgid "Save Member" msgstr "Mitglied speichern" -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_value_live/form.ex -#: lib/mv_web/live/global_settings_live.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_value_live/form.ex:74 +#: lib/mv_web/live/global_settings_live.ex:55 +#: lib/mv_web/live/member_live/form.ex:78 +#: lib/mv_web/live/user_live/form.ex:248 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "Speichern..." -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/form.ex:54 +#: lib/mv_web/live/member_live/index.html.heex:130 +#: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "Street" msgstr "Straße" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/member_live/index/formatter.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:47 +#, elixir-autogen, elixir-format +msgid "Id" +msgstr "ID" + +#: lib/mv_web/live/member_live/index.html.heex:234 +#: lib/mv_web/live/member_live/index/formatter.ex:61 +#: lib/mv_web/live/member_live/show.ex:52 #, elixir-autogen, elixir-format msgid "No" msgstr "Nein" -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:115 #, elixir-autogen, elixir-format, fuzzy msgid "Show Member" msgstr "Mitglied anzeigen" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/member_live/index/formatter.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:33 +#, elixir-autogen, elixir-format +msgid "This is a member record from your database." +msgstr "Dies ist ein Mitglied aus deiner Datenbank." + +#: lib/mv_web/live/member_live/index.html.heex:234 +#: lib/mv_web/live/member_live/index/formatter.ex:60 +#: lib/mv_web/live/member_live/show.ex:52 #, elixir-autogen, elixir-format msgid "Yes" msgstr "Ja" -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_value_live/form.ex -#: lib/mv_web/live/member_live/form.ex +#: lib/mv_web/live/custom_field_live/form.ex:110 +#: lib/mv_web/live/custom_field_value_live/form.ex:233 +#: lib/mv_web/live/member_live/form.ex:137 #, elixir-autogen, elixir-format msgid "create" msgstr "erstellt" -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_value_live/form.ex -#: lib/mv_web/live/member_live/form.ex +#: lib/mv_web/live/custom_field_live/form.ex:111 +#: lib/mv_web/live/custom_field_value_live/form.ex:234 +#: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "update" msgstr "aktualisiert" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:60 #, elixir-autogen, elixir-format msgid "Incorrect email or password" msgstr "Falsche E-Mail oder Passwort" -#: lib/mv_web/live/member_live/form.ex +#: lib/mv_web/live/member_live/form.ex:144 #, elixir-autogen, elixir-format msgid "Member %{action} successfully" msgstr "Mitglied %{action} erfolgreich" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:26 #, elixir-autogen, elixir-format msgid "You are now signed in" msgstr "Sie sind jetzt angemeldet" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:186 #, elixir-autogen, elixir-format msgid "You are now signed out" msgstr "Sie sind jetzt abgemeldet" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:85 #, elixir-autogen, elixir-format msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n" msgstr "Sie haben sich bereits auf andere Weise angemeldet, aber Ihr Konto noch nicht bestÀtigt.\nSie kânnen Ihr Konto über den Link bestÀtigen, den wir Ihnen gesendet haben, oder durch Zurücksetzen Ihres Passworts.\n" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:24 #, elixir-autogen, elixir-format msgid "Your email address has now been confirmed" msgstr "Ihre E-Mail-Adresse wurde bestÀtigt" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:25 #, elixir-autogen, elixir-format msgid "Your password has successfully been reset" msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt" -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/custom_field_value_live/form.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/custom_field_live/form.ex:69 +#: lib/mv_web/live/custom_field_live/index.ex:120 +#: lib/mv_web/live/custom_field_value_live/form.ex:77 +#: lib/mv_web/live/member_live/form.ex:81 +#: lib/mv_web/live/user_live/form.ex:251 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "Abbrechen" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Choose a member" msgstr "Mitglied auswÀhlen" -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Description" msgstr "Beschreibung" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:43 #, elixir-autogen, elixir-format msgid "Edit User" msgstr "Benutzer*in bearbeiten" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Enabled" msgstr "Aktiviert" -#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/user_live/show.ex:49 +#, elixir-autogen, elixir-format +msgid "ID" +msgstr "ID" + +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "UnverÀnderlich" -#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/components/layouts/navbar.ex:102 #, elixir-autogen, elixir-format msgid "Logout" msgstr "Abmelden" -#: lib/mv_web/live/user_live/index.ex -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/user_live/index.ex:33 +#: lib/mv_web/live/user_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Listing Users" msgstr "Benutzer*innen auflisten" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Member" msgstr "Mitglied" -#: lib/mv_web/components/layouts/navbar.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#: lib/mv_web/live/member_live/index.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/components/layouts/navbar.ex:25 +#: lib/mv_web/live/member_live/index.ex:73 +#: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "Mitglieder" -#: lib/mv_web/live/contribution_type_live/index.ex -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Name" msgstr "Name" -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/user_live/index.html.heex:6 #, elixir-autogen, elixir-format msgid "New User" msgstr "Neue*r Benutzer*in" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Not enabled" msgstr "Nicht aktiviert" -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex:51 +#, elixir-autogen, elixir-format +msgid "Not set" +msgstr "Nicht gesetzt" + +#: lib/mv_web/live/user_live/form.ex:107 +#: lib/mv_web/live/user_live/form.ex:115 +#: lib/mv_web/live/user_live/form.ex:224 #, elixir-autogen, elixir-format msgid "Note" msgstr "Hinweis" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/index.html.heex:52 +#: lib/mv_web/live/user_live/show.ex:51 +#, elixir-autogen, elixir-format +msgid "OIDC ID" +msgstr "OIDC ID" + +#: lib/mv_web/live/user_live/show.ex:52 #, elixir-autogen, elixir-format msgid "Password Authentication" msgstr "Passwort-Authentifizierung" -#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/components/layouts/navbar.ex:95 #, elixir-autogen, elixir-format msgid "Profil" msgstr "Profil" -#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:63 #, elixir-autogen, elixir-format msgid "Required" msgstr "Erforderlich" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:68 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "Alle Mitglieder auswÀhlen" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:82 #, elixir-autogen, elixir-format msgid "Select member" msgstr "Mitglied auswÀhlen" -#: lib/mv_web/components/layouts/navbar.ex -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/components/layouts/navbar.ex:99 #, elixir-autogen, elixir-format msgid "Settings" msgstr "Einstellungen" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:249 #, elixir-autogen, elixir-format msgid "Save User" msgstr "Benutzer*in speichern" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:79 #, elixir-autogen, elixir-format msgid "Show User" msgstr "Benutzer*in anzeigen" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:35 #, elixir-autogen, elixir-format msgid "This is a user record from your database." msgstr "Dies ist ein Benutzer*innen-Datensatz aus Ihrer Datenbank." -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:128 #, elixir-autogen, elixir-format msgid "Unsupported value type: %{type}" msgstr "Nicht unterstützter Wertetyp: %{type}" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:42 #, elixir-autogen, elixir-format msgid "Use this form to manage user records in your database." msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-DatensÀtze zu verwalten." -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/form.ex:266 +#: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" msgstr "Benutzer*in" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:92 #, elixir-autogen, elixir-format msgid "Value" msgstr "Wert" -#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:56 #, elixir-autogen, elixir-format msgid "Value type" msgstr "Wertetyp" -#: lib/mv_web/components/table_components.ex -#: lib/mv_web/live/components/sort_header_component.ex +#: lib/mv_web/components/table_components.ex:30 +#: lib/mv_web/live/components/sort_header_component.ex:57 #, elixir-autogen, elixir-format msgid "ascending" msgstr "aufsteigend" -#: lib/mv_web/components/table_components.ex -#: lib/mv_web/live/components/sort_header_component.ex +#: lib/mv_web/components/table_components.ex:30 +#: lib/mv_web/live/components/sort_header_component.ex:58 #, elixir-autogen, elixir-format msgid "descending" msgstr "absteigend" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:265 #, elixir-autogen, elixir-format msgid "New" msgstr "Neue*r" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:96 #, elixir-autogen, elixir-format msgid "Admin Note" msgstr "Administrator*innen-Hinweis" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:96 #, elixir-autogen, elixir-format msgid "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system." msgstr "Als Administrator*in kânnen Sie direkt ein neues Passwort für diese*n Benutzer*in setzen, wobei das gleiche sichere Ash Authentication System verwendet wird." -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:87 #, elixir-autogen, elixir-format msgid "At least 8 characters" msgstr "Mindestens 8 Zeichen" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:59 #, elixir-autogen, elixir-format msgid "Change Password" msgstr "Passwort Àndern" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:107 #, elixir-autogen, elixir-format msgid "Check 'Change Password' above to set a new password for this user." msgstr "Aktivieren Sie 'Passwort Àndern' oben, um ein neues Passwort für diese*n Benutzer*in zu setzen." -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:77 #, elixir-autogen, elixir-format msgid "Confirm Password" msgstr "Passwort bestÀtigen" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:89 #, elixir-autogen, elixir-format msgid "Consider using special characters" msgstr "Sonderzeichen empfohlen" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:88 #, elixir-autogen, elixir-format msgid "Include both letters and numbers" msgstr "Buchstaben und Zahlen verwenden" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Password" msgstr "Passwort" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:85 #, elixir-autogen, elixir-format msgid "Password requirements" msgstr "Passwort-Anforderungen" -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/user_live/index.html.heex:21 #, elixir-autogen, elixir-format msgid "Select all users" msgstr "Alle Benutzer*innen auswÀhlen" -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/user_live/index.html.heex:35 #, elixir-autogen, elixir-format msgid "Select user" msgstr "Benutzer*in auswÀhlen" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:59 #, elixir-autogen, elixir-format msgid "Set Password" msgstr "Passwort setzen" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:115 #, elixir-autogen, elixir-format msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "Benutzer*in wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufügen." -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/index.html.heex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/form.ex:126 +#: lib/mv_web/live/user_live/index.html.heex:53 +#: lib/mv_web/live/user_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Linked Member" msgstr "Verknüpftes Mitglied" -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Linked User" msgstr "Verknüpfte*r Benutzer*in" -#: lib/mv_web/live/user_live/index.html.heex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/index.html.heex:57 +#: lib/mv_web/live/user_live/show.ex:65 #, elixir-autogen, elixir-format msgid "No member linked" msgstr "Kein Mitglied verknüpft" -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:72 #, elixir-autogen, elixir-format msgid "No user linked" msgstr "Keine*r Benutzer*in verknüpft" -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:36 +#: lib/mv_web/live/member_live/show.ex:38 #, elixir-autogen, elixir-format msgid "Back to members list" msgstr "Zurück zur Mitgliederliste" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:38 +#: lib/mv_web/live/user_live/show.ex:40 #, elixir-autogen, elixir-format msgid "Back to users list" msgstr "Zurück zur Benutzer*innen-Liste" -#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/components/layouts/navbar.ex:33 +#: lib/mv_web/components/layouts/navbar.ex:39 #, elixir-autogen, elixir-format msgid "Select language" msgstr "Sprache auswÀhlen" -#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/components/layouts/navbar.ex:46 +#: lib/mv_web/components/layouts/navbar.ex:66 #, elixir-autogen, elixir-format msgid "Toggle dark mode" msgstr "Dunklen Modus umschalten" -#: lib/mv_web/live/components/search_bar_component.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/components/search_bar_component.ex:15 +#: lib/mv_web/live/member_live/index.html.heex:39 #, elixir-autogen, elixir-format msgid "Search..." msgstr "Suchen..." -#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/components/layouts/navbar.ex:27 #, elixir-autogen, elixir-format msgid "Users" msgstr "Benutzer*innen" -#: lib/mv_web/live/components/sort_header_component.ex +#: lib/mv_web/live/components/sort_header_component.ex:59 +#: lib/mv_web/live/components/sort_header_component.ex:63 #, elixir-autogen, elixir-format msgid "Click to sort" msgstr "Klicke um zu sortieren" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:94 #, elixir-autogen, elixir-format msgid "First name" msgstr "Vorname" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:167 #, elixir-autogen, elixir-format msgid "An account with this email already exists. Please verify your password to link your OIDC account." msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifizieren Sie Ihr Passwort, um Ihr OIDC-Konto zu verknüpfen." -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:77 #, elixir-autogen, elixir-format msgid "Unable to authenticate with OIDC. Please try again." msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut." -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:152 #, elixir-autogen, elixir-format msgid "Unable to sign in. Please try again." msgstr "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut." -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:92 +#: lib/mv_web/controllers/auth_controller.ex:97 #, elixir-autogen, elixir-format msgid "Authentication failed. Please try again." msgstr "Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut." -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:124 #, elixir-autogen, elixir-format msgid "Cannot update email: This email is already registered to another account. Please change your email in the identity provider." msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits für ein anderes Konto registriert. Bitte Àndern Sie Ihre E-Mail-Adresse im Identity-Provider." -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:130 #, elixir-autogen, elixir-format msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft. Es kânnen nicht mehrere OIDC-Provider mit demselben Konto verknüpft werden." -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:53 #, elixir-autogen, elixir-format msgid "Choose a custom field" msgstr "WÀhle ein Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_value_live/form.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Custom field" -msgstr "Benutzerdefinierte Felder" +#: lib/mv_web/live/member_live/form.ex:58 +#: lib/mv_web/live/member_live/show.ex:77 +#, elixir-autogen, elixir-format +msgid "Custom Field Values" +msgstr "Benutzerdefinierte Feldwerte" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:51 +#, elixir-autogen, elixir-format +msgid "Custom field" +msgstr "Benutzerdefiniertes Feld" + +#: lib/mv_web/live/custom_field_live/form.ex:117 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "Benutzerdefiniertes Feld erfolgreich %{action}" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:242 #, elixir-autogen, elixir-format msgid "Custom field value %{action} successfully" msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:70 #, elixir-autogen, elixir-format msgid "Please select a custom field first" msgstr "Bitte wÀhle zuerst ein Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "Benutzerdefiniertes Feld speichern" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:75 #, elixir-autogen, elixir-format msgid "Save Custom field value" msgstr "Benutzerdefinierten Feldwert speichern" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/custom_field_live/form.ex:46 +#, elixir-autogen, elixir-format +msgid "Use this form to manage custom_field records in your database." +msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten." + +#: lib/mv_web/components/layouts/navbar.ex:26 #, elixir-autogen, elixir-format msgid "Custom Fields" msgstr "Benutzerdefinierte Felder" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:42 #, elixir-autogen, elixir-format, fuzzy msgid "Use this form to manage Custom Field Value records in your database." msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten." -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/show.ex:56 +#, elixir-autogen, elixir-format +msgid "Auto-generated identifier (immutable)" +msgstr "Automatisch generierter Bezeichner (unverÀnderlich)" + +#: lib/mv_web/live/custom_field_live/index.ex:79 #, elixir-autogen, elixir-format msgid "%{count} member has a value assigned for this custom field." msgid_plural "%{count} members have values assigned for this custom field." msgstr[0] "%{count} Mitglied hat einen Wert für dieses benutzerdefinierte Feld zugewiesen." msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld zugewiesen." -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/index.ex:87 #, elixir-autogen, elixir-format msgid "All custom field values will be permanently deleted when you delete this custom field." msgstr "Alle benutzerdefinierten Feldwerte werden beim Lâschen dieses benutzerdefinierten Feldes dauerhaft gelâscht." -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/index.ex:72 #, elixir-autogen, elixir-format msgid "Delete Custom Field" msgstr "Benutzerdefiniertes Feld lâschen" -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/index.ex:127 #, elixir-autogen, elixir-format msgid "Delete Custom Field and All Values" msgstr "Benutzerdefiniertes Feld und alle Werte lâschen" -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/index.ex:109 #, elixir-autogen, elixir-format msgid "Enter the text above to confirm" msgstr "Obigen Text zur BestÀtigung eingeben" -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/index.ex:97 #, elixir-autogen, elixir-format, fuzzy msgid "To confirm deletion, please enter this text:" msgstr "Um die Lâschung zu bestÀtigen, gib bitte folgenden Text ein:" -#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:64 #, elixir-autogen, elixir-format msgid "Show in overview" msgstr "In der Mitglieder-Übersicht anzeigen" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/global_settings_live.ex:51 #, elixir-autogen, elixir-format msgid "Association Name" msgstr "Vereinsname" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/global_settings_live.ex:31 +#: lib/mv_web/live/global_settings_live.ex:41 #, elixir-autogen, elixir-format, fuzzy msgid "Club Settings" msgstr "Vereinsdaten" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/global_settings_live.ex:43 #, elixir-autogen, elixir-format msgid "Manage global settings for the association." msgstr "Passe übergreifende Einstellungen für den Verein an." -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/global_settings_live.ex:56 #, elixir-autogen, elixir-format, fuzzy msgid "Save Settings" msgstr "Einstellungen speichern" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/global_settings_live.ex:75 #, elixir-autogen, elixir-format msgid "Settings updated successfully" msgstr "Einstellungen erfolgreich gespeichert" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:224 #, elixir-autogen, elixir-format msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, Àndern Sie bitte zuerst eine der E-Mail-Adressen." -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:192 #, elixir-autogen, elixir-format msgid "Available members" msgstr "Verfügbare Mitglieder" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:357 #, elixir-autogen, elixir-format msgid "Failed to link member: %{error}" msgstr "Fehler beim Verlinken des Mitglieds: %{error}" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:152 #, elixir-autogen, elixir-format msgid "Member will be unlinked when you save. Cannot select new member until saved." msgstr "Mitglied wird beim Speichern entverknüpft. Neues Mitglied kann erst nach dem Speichern ausgewÀhlt werden." -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:240 #, elixir-autogen, elixir-format msgid "Save to confirm linking." msgstr "Speichern, um die Verknüpfung zu bestÀtigen." -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:171 #, elixir-autogen, elixir-format msgid "Search for a member to link..." msgstr "Nach einem Mitglied zum Verknüpfen suchen..." -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:175 #, elixir-autogen, elixir-format msgid "Search for member to link" msgstr "Nach Mitglied zum Verknüpfen suchen" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:237 #, elixir-autogen, elixir-format msgid "Selected" msgstr "AusgewÀhlt" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:143 #, elixir-autogen, elixir-format msgid "Unlink Member" msgstr "Mitglied entverknüpfen" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:152 #, elixir-autogen, elixir-format msgid "Unlinking scheduled" msgstr "Entverknüpfung geplant" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/index.ex:159 #, elixir-autogen, elixir-format msgid "Copied %{count} email address to clipboard" msgid_plural "Copied %{count} email addresses to clipboard" msgstr[0] "%{count} E-Mail-Adresse in die Zwischenablage kopiert" msgstr[1] "%{count} E-Mail-Adressen in die Zwischenablage kopiert" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:10 #, elixir-autogen, elixir-format msgid "Copy email addresses of selected members" msgstr "E-Mail-Adressen der ausgewÀhlten Mitglieder kopieren" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:13 #, elixir-autogen, elixir-format msgid "Copy emails" msgstr "E-Mails kopieren" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/index.ex:148 #, elixir-autogen, elixir-format msgid "No email addresses found" msgstr "Keine E-Mail-Adressen gefunden" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/index.ex:145 #, elixir-autogen, elixir-format msgid "No members selected" msgstr "Keine Mitglieder ausgewÀhlt" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:23 #, elixir-autogen, elixir-format msgid "Open email program with BCC recipients" msgstr "E-Mail-Programm mit BCC-EmpfÀnger*innen âffnen" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:26 #, elixir-autogen, elixir-format msgid "Open in email program" msgstr "Im E-Mail-Programm âffnen" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/index.ex:168 #, elixir-autogen, elixir-format msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen für DatenschutzkonformitÀt" -#: lib/mv_web/components/core_components.ex +#: lib/mv_web/live/member_live/form.ex:40 +#, elixir-autogen, elixir-format +msgid "Fields marked with an asterisk (*) cannot be empty." +msgstr "Felder, die mit einem Sternchen (*) markiert sind, dürfen nicht leer bleiben." + +#: lib/mv_web/components/core_components.ex:206 +#: lib/mv_web/components/core_components.ex:223 +#: lib/mv_web/components/core_components.ex:250 +#: lib/mv_web/components/core_components.ex:277 #, elixir-autogen, elixir-format, fuzzy msgid "This field cannot be empty" msgstr "Dieses Feld darf nicht leer bleiben" -#: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/payment_filter_component.ex +#: lib/mv_web/live/components/payment_filter_component.ex:80 +#: lib/mv_web/live/components/payment_filter_component.ex:143 #, elixir-autogen, elixir-format msgid "All" msgstr "Alle" -#: lib/mv_web/live/components/payment_filter_component.ex +#: lib/mv_web/live/components/payment_filter_component.ex:54 #, elixir-autogen, elixir-format msgid "Filter by payment status" msgstr "Nach Zahlungsstatus filtern" -#: lib/mv_web/live/components/payment_filter_component.ex +#: lib/mv_web/live/components/payment_filter_component.ex:108 +#: lib/mv_web/live/components/payment_filter_component.ex:145 #, elixir-autogen, elixir-format msgid "Not paid" msgstr "Nicht bezahlt" -#: lib/mv_web/live/components/payment_filter_component.ex +#: lib/mv_web/live/components/payment_filter_component.ex:65 #, elixir-autogen, elixir-format msgid "Payment filter" msgstr "Zahlungsfilter" -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Address" -msgstr "Adresse" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Back" -msgstr "Zurück" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Coming soon" -msgstr "DemnÀchst verfügbar" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Contact Data" -msgstr "Kontaktdaten" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Contribution" -msgstr "Beitrag" - -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "Nr." -msgstr "Nr." - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Payment Cycle" -msgstr "Zahlungszyklus" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Payment Data" -msgstr "Beitragsdaten" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Payments" -msgstr "Zahlungen" - -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Pending" -msgstr "Ausstehend" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Personal Data" -msgstr "Persânliche Daten" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Phone" -msgstr "Telefon" - -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "Save" -msgstr "Speichern" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "This data is for demonstration purposes only (mockup)." -msgstr "Diese Daten dienen nur zu Demonstrationszwecken (Mockup)." - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "monthly" -msgstr "monatlich" - -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "yearly" -msgstr "jÀhrlich" - -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "Create Member" -msgstr "Mitglied erstellen" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "%{count} period selected" -msgid_plural "%{count} periods selected" -msgstr[0] "" -msgstr[1] "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "About Contribution Types" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Amount" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Back to Settings" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Can be changed at any time. Amount changes affect future periods only." -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Cannot delete - members assigned" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Change Contribution Type" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Configure global settings for membership contributions." -msgstr "" - -#: lib/mv_web/components/layouts/navbar.ex -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Contribution Settings" -msgstr "Beitrag" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Contribution Start" -msgstr "Beitrag" - -#: lib/mv_web/components/layouts/navbar.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Contribution Types" -msgstr "Beitrag" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Contribution start" -msgstr "Beitrag" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Contribution type" -msgstr "Beitrag" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Contribution types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation." -msgstr "" - -#: lib/mv_web/components/layouts/navbar.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Contributions" -msgstr "Beitrag" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Contributions for %{name}" -msgstr "Beitrag" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Current" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Default Contribution Type" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Deletion" -msgstr "Lâschen" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Example: Member Contribution View" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Examples" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Family" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Fixed after creation. Members can only switch between types with the same interval." -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Generated periods" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Global Settings" -msgstr "Vereinsdaten" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Half-yearly" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Half-yearly contribution for supporting members" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Honorary" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Include joining period" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Interval" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Joining date" -msgstr "Beitrittsdatum" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Joining year - reduced to 0" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Manage contribution types for membership fees." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Mark as Paid" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Mark as Suspended" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Mark as Unpaid" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Member Contributions" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Member pays for the year they joined" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Member pays from the joining month" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Member pays from the next full quarter" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Member pays from the next full year" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Member since" -msgstr "Mitglieder" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Monthly" -msgstr "monatlich" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Monthly Interval - Joining Period Included" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Monthly fee for students and trainees" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Name & Amount" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "New Contribution Type" -msgstr "Beitrag" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "No fee for honorary members" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Only possible if no members are assigned to this type." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Open Contributions" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Paid via bank transfer" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Preview Mockup" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Quarterly" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Quarterly Interval - Joining Period Excluded" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Quarterly fee for family memberships" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Reduced" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Reduced fee for unemployed, pensioners, or low income" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Regular" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Reopen" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods." -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Standard membership fee for regular members" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Status" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Student" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Supporting Member" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Suspend" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Suspended" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "This page is not functional and only displays the planned features." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Time Period" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Total Contributions" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Unpaid" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "View Example Member" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "When active: Members pay from the period of their joining." -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "When inactive: Members pay from the next full period after joining." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Why are not all contribution types shown?" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Yearly" -msgstr "jÀhrlich" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Yearly Interval - Joining Period Excluded" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Yearly Interval - Joining Period Included" -msgstr "" - -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Columns" -msgstr "Spalten" - -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Custom Field %{id}" -msgstr "Benutzerdefiniertes Feld %{id}" - -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Last name" -msgstr "Nachname" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format -msgid "None" -msgstr "Keine" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format -msgid "Options" -msgstr "Optionen" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format -msgid "Select all" -msgstr "Alle auswÀhlen" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format -msgid "Select none" -msgstr "Keine auswÀhlen" - -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Back to custom field overview" -msgstr "Zurück zur Felderliste" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Custom field deleted successfully" -msgstr "Benutzerdefiniertes Feld erfolgreich %{action}" - -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Edit Custom Field" -msgstr "Benutzerdefiniertes Feld lâschen" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Failed to delete custom field: %{error}" -msgstr "Konnte Feld nicht lâschen: %{error}" - -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "New Custom Field" -msgstr "Benutzerdefiniertes Feld speichern" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "New Custom field" -msgstr "Benutzerdefiniertes Feld speichern" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Show in Overview" -msgstr "In der Mitglieder-Übersicht anzeigen" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Slug does not match. Deletion cancelled." -msgstr "Eingegebener Text war nicht korrekt. Vorgang abgebrochen." - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "These will appear in addition to other data when adding new members." -msgstr "Diese Felder kânnen zusÀtzlich zu den normalen Daten ausgefüllt werden, wenn ein neues Mitglied angelegt wird." - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Value Type" -msgstr "Wertetyp" - -#~ #: lib/mv_web/live/custom_field_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Auto-generated identifier (immutable)" -#~ msgstr "Automatisch generierter Bezeichner (unverÀnderlich)" - -#~ #: lib/mv_web/live/member_live/form.ex -#~ #: lib/mv_web/live/member_live/show.ex +#~ #: lib/mv_web/live/member_live/form.ex:48 +#~ #: lib/mv_web/live/member_live/show.ex:51 #~ #, elixir-autogen, elixir-format #~ msgid "Birth Date" #~ msgstr "Geburtsdatum" - -#~ #: lib/mv_web/live/member_live/form.ex -#~ #: lib/mv_web/live/member_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Custom Field Values" -#~ msgstr "Benutzerdefinierte Feldwerte" - -#~ #: lib/mv_web/live/member_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Fields marked with an asterisk (*) cannot be empty." -#~ msgstr "Felder, die mit einem Sternchen (*) markiert sind, dürfen nicht leer bleiben." - -#~ #: lib/mv_web/live/custom_field_live/form.ex -#~ #: lib/mv_web/live/user_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "ID" -#~ msgstr "ID" - -#~ #: lib/mv_web/live/member_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Id" -#~ msgstr "ID" - -#~ #: lib/mv_web/live/user_live/form.ex -#~ #: lib/mv_web/live/user_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Not set" -#~ msgstr "Nicht gesetzt" - -#~ #: lib/mv_web/live/user_live/index.html.heex -#~ #: lib/mv_web/live/user_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "OIDC ID" -#~ msgstr "OIDC ID" - -#~ #: lib/mv_web/live/member_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "This is a member record from your database." -#~ msgstr "Dies ist ein Mitglied aus deiner Datenbank." - -#~ #: lib/mv_web/live/custom_field_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Use this form to manage custom_field records in your database." -#~ msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 7581d62..1e0e954 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -11,1397 +11,845 @@ msgid "" msgstr "" -#: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/contribution_period_live/show.ex +#: lib/mv_web/components/core_components.ex:386 #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:248 +#: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" -#: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts.ex:82 +#: lib/mv_web/components/layouts.ex:94 #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/form.ex:53 +#: lib/mv_web/live/member_live/index.html.heex:184 +#: lib/mv_web/live/member_live/show.ex:58 #, elixir-autogen, elixir-format msgid "City" msgstr "" -#: lib/mv_web/live/contribution_type_live/index.ex -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:250 +#: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/contribution_type_live/index.ex -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:242 +#: lib/mv_web/live/user_live/form.ex:265 +#: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:41 +#: lib/mv_web/live/member_live/show.ex:116 #, elixir-autogen, elixir-format msgid "Edit Member" msgstr "" -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/index.html.heex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/member_live/form.ex:47 +#: lib/mv_web/live/member_live/index.html.heex:112 +#: lib/mv_web/live/member_live/show.ex:50 +#: lib/mv_web/live/user_live/form.ex:46 +#: lib/mv_web/live/user_live/index.html.heex:44 +#: lib/mv_web/live/user_live/show.ex:50 #, elixir-autogen, elixir-format msgid "Email" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/form.ex:45 +#: lib/mv_web/live/member_live/show.ex:48 #, elixir-autogen, elixir-format msgid "First Name" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/form.ex:50 +#: lib/mv_web/live/member_live/index.html.heex:220 +#: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Join Date" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/form.ex:46 +#: lib/mv_web/live/member_live/show.ex:49 #, elixir-autogen, elixir-format msgid "Last Name" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:29 #, elixir-autogen, elixir-format msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:239 +#: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" msgstr "" -#: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts.ex:89 #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "" -#: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts.ex:77 #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "" -#: lib/mv_web/components/core_components.ex +#: lib/mv_web/components/core_components.ex:82 #, elixir-autogen, elixir-format msgid "close" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/form.ex:51 +#: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Exit Date" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/form.ex:55 +#: lib/mv_web/live/member_live/index.html.heex:148 +#: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "House Number" msgstr "" -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/form.ex:52 +#: lib/mv_web/live/member_live/show.ex:57 #, elixir-autogen, elixir-format msgid "Notes" msgstr "" -#: lib/mv_web/live/components/payment_filter_component.ex -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/components/payment_filter_component.ex:94 +#: lib/mv_web/live/components/payment_filter_component.ex:144 +#: lib/mv_web/live/member_live/form.ex:48 +#: lib/mv_web/live/member_live/index.html.heex:229 +#: lib/mv_web/live/member_live/show.ex:51 #, elixir-autogen, elixir-format msgid "Paid" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/form.ex:49 +#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/member_live/show.ex:54 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/form.ex:56 +#: lib/mv_web/live/member_live/index.html.heex:166 +#: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "Postal Code" msgstr "" -#: lib/mv_web/live/member_live/form.ex +#: lib/mv_web/live/member_live/form.ex:79 #, elixir-autogen, elixir-format msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_value_live/form.ex -#: lib/mv_web/live/global_settings_live.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_value_live/form.ex:74 +#: lib/mv_web/live/global_settings_live.ex:55 +#: lib/mv_web/live/member_live/form.ex:78 +#: lib/mv_web/live/user_live/form.ex:248 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/form.ex:54 +#: lib/mv_web/live/member_live/index.html.heex:130 +#: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "Street" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/member_live/index/formatter.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:47 +#, elixir-autogen, elixir-format +msgid "Id" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex:234 +#: lib/mv_web/live/member_live/index/formatter.ex:61 +#: lib/mv_web/live/member_live/show.ex:52 #, elixir-autogen, elixir-format msgid "No" msgstr "" -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:115 #, elixir-autogen, elixir-format msgid "Show Member" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/member_live/index/formatter.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:33 +#, elixir-autogen, elixir-format +msgid "This is a member record from your database." +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex:234 +#: lib/mv_web/live/member_live/index/formatter.ex:60 +#: lib/mv_web/live/member_live/show.ex:52 #, elixir-autogen, elixir-format msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_value_live/form.ex -#: lib/mv_web/live/member_live/form.ex +#: lib/mv_web/live/custom_field_live/form.ex:110 +#: lib/mv_web/live/custom_field_value_live/form.ex:233 +#: lib/mv_web/live/member_live/form.ex:137 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_value_live/form.ex -#: lib/mv_web/live/member_live/form.ex +#: lib/mv_web/live/custom_field_live/form.ex:111 +#: lib/mv_web/live/custom_field_value_live/form.ex:234 +#: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "update" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:60 #, elixir-autogen, elixir-format msgid "Incorrect email or password" msgstr "" -#: lib/mv_web/live/member_live/form.ex +#: lib/mv_web/live/member_live/form.ex:144 #, elixir-autogen, elixir-format msgid "Member %{action} successfully" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:26 #, elixir-autogen, elixir-format msgid "You are now signed in" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:186 #, elixir-autogen, elixir-format msgid "You are now signed out" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:85 #, elixir-autogen, elixir-format msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:24 #, elixir-autogen, elixir-format msgid "Your email address has now been confirmed" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:25 #, elixir-autogen, elixir-format msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/custom_field_value_live/form.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/custom_field_live/form.ex:69 +#: lib/mv_web/live/custom_field_live/index.ex:120 +#: lib/mv_web/live/custom_field_value_live/form.ex:77 +#: lib/mv_web/live/member_live/form.ex:81 +#: lib/mv_web/live/user_live/form.ex:251 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Description" msgstr "" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:43 #, elixir-autogen, elixir-format msgid "Edit User" msgstr "" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Enabled" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/user_live/show.ex:49 +#, elixir-autogen, elixir-format +msgid "ID" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/components/layouts/navbar.ex:102 #, elixir-autogen, elixir-format msgid "Logout" msgstr "" -#: lib/mv_web/live/user_live/index.ex -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/user_live/index.ex:33 +#: lib/mv_web/live/user_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Listing Users" msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#: lib/mv_web/live/member_live/index.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/components/layouts/navbar.ex:25 +#: lib/mv_web/live/member_live/index.ex:73 +#: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "" -#: lib/mv_web/live/contribution_type_live/index.ex -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Name" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/user_live/index.html.heex:6 #, elixir-autogen, elixir-format msgid "New User" msgstr "" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Not enabled" msgstr "" -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex:51 +#, elixir-autogen, elixir-format +msgid "Not set" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:107 +#: lib/mv_web/live/user_live/form.ex:115 +#: lib/mv_web/live/user_live/form.ex:224 #, elixir-autogen, elixir-format msgid "Note" msgstr "" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/index.html.heex:52 +#: lib/mv_web/live/user_live/show.ex:51 +#, elixir-autogen, elixir-format +msgid "OIDC ID" +msgstr "" + +#: lib/mv_web/live/user_live/show.ex:52 #, elixir-autogen, elixir-format msgid "Password Authentication" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/components/layouts/navbar.ex:95 #, elixir-autogen, elixir-format msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:63 #, elixir-autogen, elixir-format msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:68 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:82 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/components/layouts/navbar.ex:99 #, elixir-autogen, elixir-format msgid "Settings" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:249 #, elixir-autogen, elixir-format msgid "Save User" msgstr "" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:79 #, elixir-autogen, elixir-format msgid "Show User" msgstr "" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:35 #, elixir-autogen, elixir-format msgid "This is a user record from your database." msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:128 #, elixir-autogen, elixir-format msgid "Unsupported value type: %{type}" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:42 #, elixir-autogen, elixir-format msgid "Use this form to manage user records in your database." msgstr "" -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/form.ex:266 +#: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:92 #, elixir-autogen, elixir-format msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:56 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" -#: lib/mv_web/components/table_components.ex -#: lib/mv_web/live/components/sort_header_component.ex +#: lib/mv_web/components/table_components.ex:30 +#: lib/mv_web/live/components/sort_header_component.ex:57 #, elixir-autogen, elixir-format msgid "ascending" msgstr "" -#: lib/mv_web/components/table_components.ex -#: lib/mv_web/live/components/sort_header_component.ex +#: lib/mv_web/components/table_components.ex:30 +#: lib/mv_web/live/components/sort_header_component.ex:58 #, elixir-autogen, elixir-format msgid "descending" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:265 #, elixir-autogen, elixir-format msgid "New" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:96 #, elixir-autogen, elixir-format msgid "Admin Note" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:96 #, elixir-autogen, elixir-format msgid "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system." msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:87 #, elixir-autogen, elixir-format msgid "At least 8 characters" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:59 #, elixir-autogen, elixir-format msgid "Change Password" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:107 #, elixir-autogen, elixir-format msgid "Check 'Change Password' above to set a new password for this user." msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:77 #, elixir-autogen, elixir-format msgid "Confirm Password" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:89 #, elixir-autogen, elixir-format msgid "Consider using special characters" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:88 #, elixir-autogen, elixir-format msgid "Include both letters and numbers" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Password" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:85 #, elixir-autogen, elixir-format msgid "Password requirements" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/user_live/index.html.heex:21 #, elixir-autogen, elixir-format msgid "Select all users" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/user_live/index.html.heex:35 #, elixir-autogen, elixir-format msgid "Select user" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:59 #, elixir-autogen, elixir-format msgid "Set Password" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:115 #, elixir-autogen, elixir-format msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "" -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/index.html.heex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/form.ex:126 +#: lib/mv_web/live/user_live/index.html.heex:53 +#: lib/mv_web/live/user_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Linked Member" msgstr "" -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Linked User" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/index.html.heex:57 +#: lib/mv_web/live/user_live/show.ex:65 #, elixir-autogen, elixir-format msgid "No member linked" msgstr "" -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:72 #, elixir-autogen, elixir-format msgid "No user linked" msgstr "" -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:36 +#: lib/mv_web/live/member_live/show.ex:38 #, elixir-autogen, elixir-format msgid "Back to members list" msgstr "" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:38 +#: lib/mv_web/live/user_live/show.ex:40 #, elixir-autogen, elixir-format msgid "Back to users list" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/components/layouts/navbar.ex:33 +#: lib/mv_web/components/layouts/navbar.ex:39 #, elixir-autogen, elixir-format msgid "Select language" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/components/layouts/navbar.ex:46 +#: lib/mv_web/components/layouts/navbar.ex:66 #, elixir-autogen, elixir-format msgid "Toggle dark mode" msgstr "" -#: lib/mv_web/live/components/search_bar_component.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/components/search_bar_component.ex:15 +#: lib/mv_web/live/member_live/index.html.heex:39 #, elixir-autogen, elixir-format msgid "Search..." msgstr "" -#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/components/layouts/navbar.ex:27 #, elixir-autogen, elixir-format msgid "Users" msgstr "" -#: lib/mv_web/live/components/sort_header_component.ex +#: lib/mv_web/live/components/sort_header_component.ex:59 +#: lib/mv_web/live/components/sort_header_component.ex:63 #, elixir-autogen, elixir-format msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:94 #, elixir-autogen, elixir-format msgid "First name" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:167 #, elixir-autogen, elixir-format msgid "An account with this email already exists. Please verify your password to link your OIDC account." msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:77 #, elixir-autogen, elixir-format msgid "Unable to authenticate with OIDC. Please try again." msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:152 #, elixir-autogen, elixir-format msgid "Unable to sign in. Please try again." msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:92 +#: lib/mv_web/controllers/auth_controller.ex:97 #, elixir-autogen, elixir-format msgid "Authentication failed. Please try again." msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:124 #, elixir-autogen, elixir-format msgid "Cannot update email: This email is already registered to another account. Please change your email in the identity provider." msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:130 #, elixir-autogen, elixir-format msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:53 #, elixir-autogen, elixir-format msgid "Choose a custom field" msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/member_live/form.ex:58 +#: lib/mv_web/live/member_live/show.ex:77 +#, elixir-autogen, elixir-format +msgid "Custom Field Values" +msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Custom field" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/custom_field_live/form.ex:117 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:242 #, elixir-autogen, elixir-format msgid "Custom field value %{action} successfully" msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:70 #, elixir-autogen, elixir-format msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:75 #, elixir-autogen, elixir-format msgid "Save Custom field value" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/custom_field_live/form.ex:46 +#, elixir-autogen, elixir-format +msgid "Use this form to manage custom_field records in your database." +msgstr "" + +#: lib/mv_web/components/layouts/navbar.ex:26 #, elixir-autogen, elixir-format msgid "Custom Fields" msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:42 #, elixir-autogen, elixir-format msgid "Use this form to manage Custom Field Value records in your database." msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/show.ex:56 +#, elixir-autogen, elixir-format +msgid "Auto-generated identifier (immutable)" +msgstr "" + +#: lib/mv_web/live/custom_field_live/index.ex:79 #, elixir-autogen, elixir-format msgid "%{count} member has a value assigned for this custom field." msgid_plural "%{count} members have values assigned for this custom field." msgstr[0] "" msgstr[1] "" -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/index.ex:87 #, elixir-autogen, elixir-format msgid "All custom field values will be permanently deleted when you delete this custom field." msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/index.ex:72 #, elixir-autogen, elixir-format msgid "Delete Custom Field" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/index.ex:127 #, elixir-autogen, elixir-format msgid "Delete Custom Field and All Values" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/index.ex:109 #, elixir-autogen, elixir-format msgid "Enter the text above to confirm" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/index.ex:97 #, elixir-autogen, elixir-format msgid "To confirm deletion, please enter this text:" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:64 #, elixir-autogen, elixir-format msgid "Show in overview" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/global_settings_live.ex:51 #, elixir-autogen, elixir-format msgid "Association Name" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/global_settings_live.ex:31 +#: lib/mv_web/live/global_settings_live.ex:41 #, elixir-autogen, elixir-format msgid "Club Settings" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/global_settings_live.ex:43 #, elixir-autogen, elixir-format msgid "Manage global settings for the association." msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/global_settings_live.ex:56 #, elixir-autogen, elixir-format msgid "Save Settings" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/global_settings_live.ex:75 #, elixir-autogen, elixir-format msgid "Settings updated successfully" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:224 #, elixir-autogen, elixir-format msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:192 #, elixir-autogen, elixir-format msgid "Available members" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:357 #, elixir-autogen, elixir-format msgid "Failed to link member: %{error}" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:152 #, elixir-autogen, elixir-format msgid "Member will be unlinked when you save. Cannot select new member until saved." msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:240 #, elixir-autogen, elixir-format msgid "Save to confirm linking." msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:171 #, elixir-autogen, elixir-format msgid "Search for a member to link..." msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:175 #, elixir-autogen, elixir-format msgid "Search for member to link" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:237 #, elixir-autogen, elixir-format msgid "Selected" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:143 #, elixir-autogen, elixir-format msgid "Unlink Member" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:152 #, elixir-autogen, elixir-format msgid "Unlinking scheduled" msgstr "" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/index.ex:159 #, elixir-autogen, elixir-format msgid "Copied %{count} email address to clipboard" msgid_plural "Copied %{count} email addresses to clipboard" msgstr[0] "" msgstr[1] "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:10 #, elixir-autogen, elixir-format msgid "Copy email addresses of selected members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:13 #, elixir-autogen, elixir-format msgid "Copy emails" msgstr "" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/index.ex:148 #, elixir-autogen, elixir-format msgid "No email addresses found" msgstr "" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/index.ex:145 #, elixir-autogen, elixir-format msgid "No members selected" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:23 #, elixir-autogen, elixir-format msgid "Open email program with BCC recipients" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:26 #, elixir-autogen, elixir-format msgid "Open in email program" msgstr "" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/index.ex:168 #, elixir-autogen, elixir-format msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "" -#: lib/mv_web/components/core_components.ex +#: lib/mv_web/live/member_live/form.ex:40 +#, elixir-autogen, elixir-format +msgid "Fields marked with an asterisk (*) cannot be empty." +msgstr "" + +#: lib/mv_web/components/core_components.ex:206 +#: lib/mv_web/components/core_components.ex:223 +#: lib/mv_web/components/core_components.ex:250 +#: lib/mv_web/components/core_components.ex:277 #, elixir-autogen, elixir-format msgid "This field cannot be empty" msgstr "" -#: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/payment_filter_component.ex +#: lib/mv_web/live/components/payment_filter_component.ex:80 +#: lib/mv_web/live/components/payment_filter_component.ex:143 #, elixir-autogen, elixir-format msgid "All" msgstr "" -#: lib/mv_web/live/components/payment_filter_component.ex +#: lib/mv_web/live/components/payment_filter_component.ex:54 #, elixir-autogen, elixir-format msgid "Filter by payment status" msgstr "" -#: lib/mv_web/live/components/payment_filter_component.ex +#: lib/mv_web/live/components/payment_filter_component.ex:108 +#: lib/mv_web/live/components/payment_filter_component.ex:145 #, elixir-autogen, elixir-format msgid "Not paid" msgstr "" -#: lib/mv_web/live/components/payment_filter_component.ex +#: lib/mv_web/live/components/payment_filter_component.ex:65 #, elixir-autogen, elixir-format msgid "Payment filter" msgstr "" - -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Address" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Back" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Coming soon" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Contact Data" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Contribution" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "Nr." -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Payment Cycle" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Payment Data" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Payments" -msgstr "" - -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Pending" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Personal Data" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Phone" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "Save" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "This data is for demonstration purposes only (mockup)." -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "monthly" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "yearly" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "Create Member" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "%{count} period selected" -msgid_plural "%{count} periods selected" -msgstr[0] "" -msgstr[1] "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "About Contribution Types" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Amount" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Back to Settings" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Can be changed at any time. Amount changes affect future periods only." -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Cannot delete - members assigned" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Change Contribution Type" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Configure global settings for membership contributions." -msgstr "" - -#: lib/mv_web/components/layouts/navbar.ex -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Contribution Settings" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Contribution Start" -msgstr "" - -#: lib/mv_web/components/layouts/navbar.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Contribution Types" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Contribution start" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Contribution type" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Contribution types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation." -msgstr "" - -#: lib/mv_web/components/layouts/navbar.ex -#, elixir-autogen, elixir-format -msgid "Contributions" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Contributions for %{name}" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Current" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Default Contribution Type" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Deletion" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Example: Member Contribution View" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Examples" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Family" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Fixed after creation. Members can only switch between types with the same interval." -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Generated periods" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Global Settings" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Half-yearly" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Half-yearly contribution for supporting members" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Honorary" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Include joining period" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Interval" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Joining date" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Joining year - reduced to 0" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Manage contribution types for membership fees." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Mark as Paid" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Mark as Suspended" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Mark as Unpaid" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Member Contributions" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Member pays for the year they joined" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Member pays from the joining month" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Member pays from the next full quarter" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Member pays from the next full year" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Member since" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Monthly" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Monthly Interval - Joining Period Included" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Monthly fee for students and trainees" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Name & Amount" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "New Contribution Type" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "No fee for honorary members" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Only possible if no members are assigned to this type." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Open Contributions" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Paid via bank transfer" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Preview Mockup" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Quarterly" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Quarterly Interval - Joining Period Excluded" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Quarterly fee for family memberships" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Reduced" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Reduced fee for unemployed, pensioners, or low income" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Regular" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Reopen" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods." -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Standard membership fee for regular members" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Status" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Student" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Supporting Member" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Suspend" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Suspended" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "This page is not functional and only displays the planned features." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Time Period" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Total Contributions" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Unpaid" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "View Example Member" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "When active: Members pay from the period of their joining." -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "When inactive: Members pay from the next full period after joining." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Why are not all contribution types shown?" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Yearly" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Yearly Interval - Joining Period Excluded" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Yearly Interval - Joining Period Included" -msgstr "" - -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Columns" -msgstr "" - -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Custom Field %{id}" -msgstr "" - -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Last name" -msgstr "" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format -msgid "None" -msgstr "" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format -msgid "Options" -msgstr "" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format -msgid "Select all" -msgstr "" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format -msgid "Select none" -msgstr "" - -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Back to custom field overview" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Custom field deleted successfully" -msgstr "" - -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Edit Custom Field" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Failed to delete custom field: %{error}" -msgstr "" - -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "New Custom Field" -msgstr "" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "New Custom field" -msgstr "" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Show in Overview" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Slug does not match. Deletion cancelled." -msgstr "" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "These will appear in addition to other data when adding new members." -msgstr "" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "Value Type" -msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/auth.po b/priv/gettext/en/LC_MESSAGES/auth.po index 561ead8..921d76b 100644 --- a/priv/gettext/en/LC_MESSAGES/auth.po +++ b/priv/gettext/en/LC_MESSAGES/auth.po @@ -32,7 +32,7 @@ msgstr "" msgid "Need an account?" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:268 #, elixir-autogen msgid "Password" msgstr "" @@ -61,77 +61,78 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:254 #, elixir-autogen, elixir-format msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:289 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:163 #, elixir-autogen, elixir-format msgid "Incorrect password. Please try again." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:37 #, elixir-autogen, elixir-format msgid "Invalid session. Please try again." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:281 #, elixir-autogen, elixir-format msgid "Link Account" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:252 #, elixir-autogen, elixir-format msgid "Link OIDC Account" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:280 #, elixir-autogen, elixir-format msgid "Linking..." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:40 #, elixir-autogen, elixir-format msgid "Session expired. Please try again." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:209 #, elixir-autogen, elixir-format msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:76 #, elixir-autogen, elixir-format msgid "Account activated! Redirecting to complete sign-in..." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:119 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:123 #, elixir-autogen, elixir-format msgid "Failed to link account. Please try again or contact support." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:108 #, elixir-autogen, elixir-format msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:98 #, elixir-autogen, elixir-format msgid "This OIDC account is already linked to another user. Please contact support." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:235 #, elixir-autogen, elixir-format msgid "Language selection" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex +#: lib/mv_web/live/auth/link_oidc_account_live.ex:242 #, elixir-autogen, elixir-format msgid "Select language" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index dc86840..319bcc3 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -11,1450 +11,851 @@ msgstr "" "Language: en\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/contribution_period_live/show.ex +#: lib/mv_web/components/core_components.ex:386 #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:248 +#: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" -#: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts.ex:82 +#: lib/mv_web/components/layouts.ex:94 #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/form.ex:53 +#: lib/mv_web/live/member_live/index.html.heex:184 +#: lib/mv_web/live/member_live/show.ex:58 #, elixir-autogen, elixir-format msgid "City" msgstr "" -#: lib/mv_web/live/contribution_type_live/index.ex -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:250 +#: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/contribution_type_live/index.ex -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:242 +#: lib/mv_web/live/user_live/form.ex:265 +#: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:41 +#: lib/mv_web/live/member_live/show.ex:116 #, elixir-autogen, elixir-format msgid "Edit Member" msgstr "" -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/index.html.heex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/member_live/form.ex:47 +#: lib/mv_web/live/member_live/index.html.heex:112 +#: lib/mv_web/live/member_live/show.ex:50 +#: lib/mv_web/live/user_live/form.ex:46 +#: lib/mv_web/live/user_live/index.html.heex:44 +#: lib/mv_web/live/user_live/show.ex:50 #, elixir-autogen, elixir-format msgid "Email" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/form.ex:45 +#: lib/mv_web/live/member_live/show.ex:48 #, elixir-autogen, elixir-format msgid "First Name" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/form.ex:50 +#: lib/mv_web/live/member_live/index.html.heex:220 +#: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Join Date" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/form.ex:46 +#: lib/mv_web/live/member_live/show.ex:49 #, elixir-autogen, elixir-format msgid "Last Name" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:29 #, elixir-autogen, elixir-format msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:239 +#: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" msgstr "" -#: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts.ex:89 #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "" -#: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts.ex:77 #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "" -#: lib/mv_web/components/core_components.ex +#: lib/mv_web/components/core_components.ex:82 #, elixir-autogen, elixir-format msgid "close" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/form.ex:51 +#: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Exit Date" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/form.ex:55 +#: lib/mv_web/live/member_live/index.html.heex:148 +#: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "House Number" msgstr "" -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/form.ex:52 +#: lib/mv_web/live/member_live/show.ex:57 #, elixir-autogen, elixir-format msgid "Notes" msgstr "" -#: lib/mv_web/live/components/payment_filter_component.ex -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/components/payment_filter_component.ex:94 +#: lib/mv_web/live/components/payment_filter_component.ex:144 +#: lib/mv_web/live/member_live/form.ex:48 +#: lib/mv_web/live/member_live/index.html.heex:229 +#: lib/mv_web/live/member_live/show.ex:51 #, elixir-autogen, elixir-format msgid "Paid" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/form.ex:49 +#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/member_live/show.ex:54 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/form.ex:56 +#: lib/mv_web/live/member_live/index.html.heex:166 +#: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "Postal Code" msgstr "" -#: lib/mv_web/live/member_live/form.ex +#: lib/mv_web/live/member_live/form.ex:79 #, elixir-autogen, elixir-format, fuzzy msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_value_live/form.ex -#: lib/mv_web/live/global_settings_live.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_value_live/form.ex:74 +#: lib/mv_web/live/global_settings_live.ex:55 +#: lib/mv_web/live/member_live/form.ex:78 +#: lib/mv_web/live/user_live/form.ex:248 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "" -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/form.ex:54 +#: lib/mv_web/live/member_live/index.html.heex:130 +#: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "Street" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/member_live/index/formatter.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:47 +#, elixir-autogen, elixir-format +msgid "Id" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex:234 +#: lib/mv_web/live/member_live/index/formatter.ex:61 +#: lib/mv_web/live/member_live/show.ex:52 #, elixir-autogen, elixir-format msgid "No" msgstr "" -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:115 #, elixir-autogen, elixir-format, fuzzy msgid "Show Member" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/live/member_live/index/formatter.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:33 +#, elixir-autogen, elixir-format +msgid "This is a member record from your database." +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex:234 +#: lib/mv_web/live/member_live/index/formatter.ex:60 +#: lib/mv_web/live/member_live/show.ex:52 #, elixir-autogen, elixir-format msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_value_live/form.ex -#: lib/mv_web/live/member_live/form.ex +#: lib/mv_web/live/custom_field_live/form.ex:110 +#: lib/mv_web/live/custom_field_value_live/form.ex:233 +#: lib/mv_web/live/member_live/form.ex:137 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_value_live/form.ex -#: lib/mv_web/live/member_live/form.ex +#: lib/mv_web/live/custom_field_live/form.ex:111 +#: lib/mv_web/live/custom_field_value_live/form.ex:234 +#: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "update" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:60 #, elixir-autogen, elixir-format msgid "Incorrect email or password" msgstr "" -#: lib/mv_web/live/member_live/form.ex +#: lib/mv_web/live/member_live/form.ex:144 #, elixir-autogen, elixir-format msgid "Member %{action} successfully" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:26 #, elixir-autogen, elixir-format msgid "You are now signed in" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:186 #, elixir-autogen, elixir-format msgid "You are now signed out" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:85 #, elixir-autogen, elixir-format msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:24 #, elixir-autogen, elixir-format msgid "Your email address has now been confirmed" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:25 #, elixir-autogen, elixir-format msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/custom_field_value_live/form.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/custom_field_live/form.ex:69 +#: lib/mv_web/live/custom_field_live/index.ex:120 +#: lib/mv_web/live/custom_field_value_live/form.ex:77 +#: lib/mv_web/live/member_live/form.ex:81 +#: lib/mv_web/live/user_live/form.ex:251 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Description" msgstr "" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:43 #, elixir-autogen, elixir-format, fuzzy msgid "Edit User" msgstr "" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Enabled" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/user_live/show.ex:49 +#, elixir-autogen, elixir-format +msgid "ID" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/components/layouts/navbar.ex:102 #, elixir-autogen, elixir-format msgid "Logout" msgstr "" -#: lib/mv_web/live/user_live/index.ex -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/user_live/index.ex:33 +#: lib/mv_web/live/user_live/index.html.heex:3 #, elixir-autogen, elixir-format, fuzzy msgid "Listing Users" msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#: lib/mv_web/live/member_live/index.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/components/layouts/navbar.ex:25 +#: lib/mv_web/live/member_live/index.ex:73 +#: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "" -#: lib/mv_web/live/contribution_type_live/index.ex -#: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Name" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/user_live/index.html.heex:6 #, elixir-autogen, elixir-format msgid "New User" msgstr "" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Not enabled" msgstr "" -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/show.ex:51 +#, elixir-autogen, elixir-format, fuzzy +msgid "Not set" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:107 +#: lib/mv_web/live/user_live/form.ex:115 +#: lib/mv_web/live/user_live/form.ex:224 #, elixir-autogen, elixir-format, fuzzy msgid "Note" msgstr "" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/index.html.heex:52 +#: lib/mv_web/live/user_live/show.ex:51 +#, elixir-autogen, elixir-format +msgid "OIDC ID" +msgstr "" + +#: lib/mv_web/live/user_live/show.ex:52 #, elixir-autogen, elixir-format msgid "Password Authentication" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/components/layouts/navbar.ex:95 #, elixir-autogen, elixir-format msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:63 #, elixir-autogen, elixir-format msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:68 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:82 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/components/layouts/navbar.ex:99 #, elixir-autogen, elixir-format msgid "Settings" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:249 #, elixir-autogen, elixir-format, fuzzy msgid "Save User" msgstr "" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:79 #, elixir-autogen, elixir-format, fuzzy msgid "Show User" msgstr "" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:35 #, elixir-autogen, elixir-format, fuzzy msgid "This is a user record from your database." msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:128 #, elixir-autogen, elixir-format msgid "Unsupported value type: %{type}" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:42 #, elixir-autogen, elixir-format, fuzzy msgid "Use this form to manage user records in your database." msgstr "" -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/form.ex:266 +#: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:92 #, elixir-autogen, elixir-format msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:56 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" -#: lib/mv_web/components/table_components.ex -#: lib/mv_web/live/components/sort_header_component.ex +#: lib/mv_web/components/table_components.ex:30 +#: lib/mv_web/live/components/sort_header_component.ex:57 #, elixir-autogen, elixir-format msgid "ascending" msgstr "" -#: lib/mv_web/components/table_components.ex -#: lib/mv_web/live/components/sort_header_component.ex +#: lib/mv_web/components/table_components.ex:30 +#: lib/mv_web/live/components/sort_header_component.ex:58 #, elixir-autogen, elixir-format msgid "descending" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:265 #, elixir-autogen, elixir-format msgid "New" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:96 #, elixir-autogen, elixir-format msgid "Admin Note" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:96 #, elixir-autogen, elixir-format msgid "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system." msgstr "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system." -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:87 #, elixir-autogen, elixir-format msgid "At least 8 characters" msgstr "At least 8 characters" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:59 #, elixir-autogen, elixir-format msgid "Change Password" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:107 #, elixir-autogen, elixir-format msgid "Check 'Change Password' above to set a new password for this user." msgstr "Check 'Change Password' above to set a new password for this user." -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:77 #, elixir-autogen, elixir-format msgid "Confirm Password" msgstr "Confirm Password" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:89 #, elixir-autogen, elixir-format msgid "Consider using special characters" msgstr "Consider using special characters" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:88 #, elixir-autogen, elixir-format msgid "Include both letters and numbers" msgstr "Include both letters and numbers" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Password" msgstr "Password" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:85 #, elixir-autogen, elixir-format msgid "Password requirements" msgstr "Password requirements" -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/user_live/index.html.heex:21 #, elixir-autogen, elixir-format, fuzzy msgid "Select all users" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex +#: lib/mv_web/live/user_live/index.html.heex:35 #, elixir-autogen, elixir-format, fuzzy msgid "Select user" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:59 #, elixir-autogen, elixir-format msgid "Set Password" msgstr "Set Password" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:115 #, elixir-autogen, elixir-format msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "User will be created without a password. Check 'Set Password' to add one." -#: lib/mv_web/live/user_live/form.ex -#: lib/mv_web/live/user_live/index.html.heex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/form.ex:126 +#: lib/mv_web/live/user_live/index.html.heex:53 +#: lib/mv_web/live/user_live/show.ex:55 #, elixir-autogen, elixir-format, fuzzy msgid "Linked Member" msgstr "" -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Linked User" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/index.html.heex:57 +#: lib/mv_web/live/user_live/show.ex:65 #, elixir-autogen, elixir-format msgid "No member linked" msgstr "" -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:72 #, elixir-autogen, elixir-format msgid "No user linked" msgstr "" -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show.ex:36 +#: lib/mv_web/live/member_live/show.ex:38 #, elixir-autogen, elixir-format msgid "Back to members list" msgstr "" -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/user_live/show.ex:38 +#: lib/mv_web/live/user_live/show.ex:40 #, elixir-autogen, elixir-format msgid "Back to users list" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/components/layouts/navbar.ex:33 +#: lib/mv_web/components/layouts/navbar.ex:39 #, elixir-autogen, elixir-format, fuzzy msgid "Select language" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/components/layouts/navbar.ex:46 +#: lib/mv_web/components/layouts/navbar.ex:66 #, elixir-autogen, elixir-format msgid "Toggle dark mode" msgstr "" -#: lib/mv_web/live/components/search_bar_component.ex -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/components/search_bar_component.ex:15 +#: lib/mv_web/live/member_live/index.html.heex:39 #, elixir-autogen, elixir-format msgid "Search..." msgstr "" -#: lib/mv_web/components/layouts/navbar.ex +#: lib/mv_web/components/layouts/navbar.ex:27 #, elixir-autogen, elixir-format, fuzzy msgid "Users" msgstr "" -#: lib/mv_web/live/components/sort_header_component.ex +#: lib/mv_web/live/components/sort_header_component.ex:59 +#: lib/mv_web/live/components/sort_header_component.ex:63 #, elixir-autogen, elixir-format msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:94 #, elixir-autogen, elixir-format, fuzzy msgid "First name" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:167 #, elixir-autogen, elixir-format msgid "An account with this email already exists. Please verify your password to link your OIDC account." msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:77 #, elixir-autogen, elixir-format msgid "Unable to authenticate with OIDC. Please try again." msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:152 #, elixir-autogen, elixir-format msgid "Unable to sign in. Please try again." msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:92 +#: lib/mv_web/controllers/auth_controller.ex:97 #, elixir-autogen, elixir-format msgid "Authentication failed. Please try again." msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:124 #, elixir-autogen, elixir-format msgid "Cannot update email: This email is already registered to another account. Please change your email in the identity provider." msgstr "" -#: lib/mv_web/controllers/auth_controller.ex +#: lib/mv_web/controllers/auth_controller.ex:130 #, elixir-autogen, elixir-format msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:53 #, elixir-autogen, elixir-format msgid "Choose a custom field" msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/member_live/form.ex:58 +#: lib/mv_web/live/member_live/show.ex:77 +#, elixir-autogen, elixir-format +msgid "Custom Field Values" +msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Custom field" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/custom_field_live/form.ex:117 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:242 #, elixir-autogen, elixir-format msgid "Custom field value %{action} successfully" msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:70 #, elixir-autogen, elixir-format msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:75 #, elixir-autogen, elixir-format msgid "Save Custom field value" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/custom_field_live/form.ex:46 +#, elixir-autogen, elixir-format, fuzzy +msgid "Use this form to manage custom_field records in your database." +msgstr "" + +#: lib/mv_web/components/layouts/navbar.ex:26 #, elixir-autogen, elixir-format, fuzzy msgid "Custom Fields" msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex +#: lib/mv_web/live/custom_field_value_live/form.ex:42 #, elixir-autogen, elixir-format, fuzzy msgid "Use this form to manage Custom Field Value records in your database." msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/show.ex:56 +#, elixir-autogen, elixir-format +msgid "Auto-generated identifier (immutable)" +msgstr "" + +#: lib/mv_web/live/custom_field_live/index.ex:79 #, elixir-autogen, elixir-format msgid "%{count} member has a value assigned for this custom field." msgid_plural "%{count} members have values assigned for this custom field." msgstr[0] "" msgstr[1] "" -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/index.ex:87 #, elixir-autogen, elixir-format msgid "All custom field values will be permanently deleted when you delete this custom field." msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/index.ex:72 #, elixir-autogen, elixir-format msgid "Delete Custom Field" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/index.ex:127 #, elixir-autogen, elixir-format msgid "Delete Custom Field and All Values" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/index.ex:109 #, elixir-autogen, elixir-format msgid "Enter the text above to confirm" msgstr "" -#: lib/mv_web/live/custom_field_live/index_component.ex +#: lib/mv_web/live/custom_field_live/index.ex:97 #, elixir-autogen, elixir-format, fuzzy msgid "To confirm deletion, please enter this text:" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex +#: lib/mv_web/live/custom_field_live/form.ex:64 #, elixir-autogen, elixir-format msgid "Show in overview" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/global_settings_live.ex:51 #, elixir-autogen, elixir-format msgid "Association Name" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/global_settings_live.ex:31 +#: lib/mv_web/live/global_settings_live.ex:41 #, elixir-autogen, elixir-format, fuzzy msgid "Club Settings" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/global_settings_live.ex:43 #, elixir-autogen, elixir-format msgid "Manage global settings for the association." msgstr "" -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/global_settings_live.ex:56 #, elixir-autogen, elixir-format, fuzzy msgid "Save Settings" msgstr "" -#: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/global_settings_live.ex:75 #, elixir-autogen, elixir-format msgid "Settings updated successfully" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:224 #, elixir-autogen, elixir-format msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:192 #, elixir-autogen, elixir-format msgid "Available members" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:357 #, elixir-autogen, elixir-format msgid "Failed to link member: %{error}" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:152 #, elixir-autogen, elixir-format msgid "Member will be unlinked when you save. Cannot select new member until saved." msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:240 #, elixir-autogen, elixir-format msgid "Save to confirm linking." msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:171 #, elixir-autogen, elixir-format msgid "Search for a member to link..." msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:175 #, elixir-autogen, elixir-format msgid "Search for member to link" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:237 #, elixir-autogen, elixir-format, fuzzy msgid "Selected" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:143 #, elixir-autogen, elixir-format msgid "Unlink Member" msgstr "" -#: lib/mv_web/live/user_live/form.ex +#: lib/mv_web/live/user_live/form.ex:152 #, elixir-autogen, elixir-format msgid "Unlinking scheduled" msgstr "" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/index.ex:159 #, elixir-autogen, elixir-format msgid "Copied %{count} email address to clipboard" msgid_plural "Copied %{count} email addresses to clipboard" msgstr[0] "" msgstr[1] "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:10 #, elixir-autogen, elixir-format msgid "Copy email addresses of selected members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:13 #, elixir-autogen, elixir-format msgid "Copy emails" msgstr "" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/index.ex:148 #, elixir-autogen, elixir-format msgid "No email addresses found" msgstr "" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/index.ex:145 #, elixir-autogen, elixir-format, fuzzy msgid "No members selected" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:23 #, elixir-autogen, elixir-format msgid "Open email program with BCC recipients" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/index.html.heex:26 #, elixir-autogen, elixir-format msgid "Open in email program" msgstr "" -#: lib/mv_web/live/member_live/index.ex +#: lib/mv_web/live/member_live/index.ex:168 #, elixir-autogen, elixir-format msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "" -#: lib/mv_web/components/core_components.ex +#: lib/mv_web/live/member_live/form.ex:40 +#, elixir-autogen, elixir-format +msgid "Fields marked with an asterisk (*) cannot be empty." +msgstr "" + +#: lib/mv_web/components/core_components.ex:206 +#: lib/mv_web/components/core_components.ex:223 +#: lib/mv_web/components/core_components.ex:250 +#: lib/mv_web/components/core_components.ex:277 #, elixir-autogen, elixir-format, fuzzy msgid "This field cannot be empty" msgstr "" -#: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/payment_filter_component.ex +#: lib/mv_web/live/components/payment_filter_component.ex:80 +#: lib/mv_web/live/components/payment_filter_component.ex:143 #, elixir-autogen, elixir-format msgid "All" msgstr "" -#: lib/mv_web/live/components/payment_filter_component.ex +#: lib/mv_web/live/components/payment_filter_component.ex:54 #, elixir-autogen, elixir-format msgid "Filter by payment status" msgstr "" -#: lib/mv_web/live/components/payment_filter_component.ex +#: lib/mv_web/live/components/payment_filter_component.ex:108 +#: lib/mv_web/live/components/payment_filter_component.ex:145 #, elixir-autogen, elixir-format msgid "Not paid" msgstr "" -#: lib/mv_web/live/components/payment_filter_component.ex +#: lib/mv_web/live/components/payment_filter_component.ex:65 #, elixir-autogen, elixir-format msgid "Payment filter" msgstr "" -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Address" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Back" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Coming soon" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Contact Data" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Contribution" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "Nr." -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Payment Cycle" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Payment Data" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Payments" -msgstr "" - -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Pending" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "Personal Data" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Phone" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Save" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "This data is for demonstration purposes only (mockup)." -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#: lib/mv_web/live/member_live/show.ex -#, elixir-autogen, elixir-format -msgid "monthly" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "yearly" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "Create Member" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "%{count} period selected" -msgid_plural "%{count} periods selected" -msgstr[0] "" -msgstr[1] "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "About Contribution Types" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Amount" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Back to Settings" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Can be changed at any time. Amount changes affect future periods only." -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Cannot delete - members assigned" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Change Contribution Type" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Configure global settings for membership contributions." -msgstr "" - -#: lib/mv_web/components/layouts/navbar.ex -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Contribution Settings" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Contribution Start" -msgstr "" - -#: lib/mv_web/components/layouts/navbar.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Contribution Types" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Contribution start" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Contribution type" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Contribution types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation." -msgstr "" - -#: lib/mv_web/components/layouts/navbar.ex -#, elixir-autogen, elixir-format -msgid "Contributions" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Contributions for %{name}" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Current" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Default Contribution Type" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Deletion" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Example: Member Contribution View" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Examples" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Family" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Fixed after creation. Members can only switch between types with the same interval." -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Generated periods" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Global Settings" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Half-yearly" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Half-yearly contribution for supporting members" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Honorary" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Include joining period" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Interval" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Joining date" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Joining year - reduced to 0" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Manage contribution types for membership fees." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Mark as Paid" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Mark as Suspended" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Mark as Unpaid" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Member Contributions" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Member pays for the year they joined" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Member pays from the joining month" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Member pays from the next full quarter" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Member pays from the next full year" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Member since" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Monthly" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Monthly Interval - Joining Period Included" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Monthly fee for students and trainees" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Name & Amount" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "New Contribution Type" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "No fee for honorary members" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Only possible if no members are assigned to this type." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Open Contributions" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Paid via bank transfer" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Preview Mockup" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Quarterly" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Quarterly Interval - Joining Period Excluded" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Quarterly fee for family memberships" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Reduced" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Reduced fee for unemployed, pensioners, or low income" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Regular" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Reopen" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods." -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Standard membership fee for regular members" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Status" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Student" -msgstr "" - -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Supporting Member" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Suspend" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Suspended" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "This page is not functional and only displays the planned features." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Time Period" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Total Contributions" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Unpaid" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "View Example Member" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "When active: Members pay from the period of their joining." -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "When inactive: Members pay from the next full period after joining." -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#, elixir-autogen, elixir-format -msgid "Why are not all contribution types shown?" -msgstr "" - -#: lib/mv_web/live/contribution_period_live/show.ex -#: lib/mv_web/live/contribution_settings_live.ex -#: lib/mv_web/live/contribution_type_live/index.ex -#, elixir-autogen, elixir-format -msgid "Yearly" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Yearly Interval - Joining Period Excluded" -msgstr "" - -#: lib/mv_web/live/contribution_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Yearly Interval - Joining Period Included" -msgstr "" - -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Columns" -msgstr "" - -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Custom Field %{id}" -msgstr "" - -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format, fuzzy -msgid "Last name" -msgstr "" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "None" -msgstr "" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Options" -msgstr "" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Select all" -msgstr "" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Select none" -msgstr "" - -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Back to custom field overview" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Custom field deleted successfully" -msgstr "" - -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Edit Custom Field" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Failed to delete custom field: %{error}" -msgstr "" - -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "New Custom Field" -msgstr "" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "New Custom field" -msgstr "" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Show in Overview" -msgstr "" - -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Slug does not match. Deletion cancelled." -msgstr "" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format -msgid "These will appear in addition to other data when adding new members." -msgstr "" - -#: lib/mv_web/live/custom_field_live/index_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Value Type" -msgstr "" - -#~ #: lib/mv_web/live/custom_field_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Auto-generated identifier (immutable)" -#~ msgstr "" - -#~ #: lib/mv_web/live/member_live/form.ex -#~ #: lib/mv_web/live/member_live/show.ex +#~ #: lib/mv_web/live/member_live/form.ex:48 +#~ #: lib/mv_web/live/member_live/show.ex:51 #~ #, elixir-autogen, elixir-format #~ msgid "Birth Date" #~ msgstr "" - -#~ #: lib/mv_web/live/member_live/form.ex -#~ #: lib/mv_web/live/member_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Custom Field Values" -#~ msgstr "" - -#~ #: lib/mv_web/live/member_live/form.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Fields marked with an asterisk (*) cannot be empty." -#~ msgstr "" - -#~ #: lib/mv_web/live/user_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "ID" -#~ msgstr "" - -#~ #: lib/mv_web/live/member_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Id" -#~ msgstr "" - -#~ #: lib/mv_web/live/user_live/show.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Not set" -#~ msgstr "" - -#~ #: lib/mv_web/live/user_live/index.html.heex -#~ #: lib/mv_web/live/user_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "OIDC ID" -#~ msgstr "" - -#~ #: lib/mv_web/live/member_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "This is a member record from your database." -#~ msgstr "" - -#~ #: lib/mv_web/live/custom_field_live/form.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Use this form to manage custom_field records in your database." -#~ msgstr "" diff --git a/test/membership/member_field_visibility_test.exs b/test/membership/member_field_visibility_test.exs index 9c7e5e0..9963169 100644 --- a/test/membership/member_field_visibility_test.exs +++ b/test/membership/member_field_visibility_test.exs @@ -11,70 +11,4 @@ defmodule Mv.Membership.MemberFieldVisibilityTest do use Mv.DataCase, async: true alias Mv.Membership.Member - - describe "show_in_overview?/1" do - test "returns true for all member fields by default" do - # When no settings exist or member_field_visibility is not configured - # Test with fields from constants - member_fields = Mv.Constants.member_fields() - - Enum.each(member_fields, fn field -> - assert Member.show_in_overview?(field) == true, - "Field #{field} should be visible by default" - end) - end - - test "returns false for fields with show_in_overview: false in settings" do - # Get or create settings - {:ok, settings} = Mv.Membership.get_settings() - - # Use a field that exists in member fields - member_fields = Mv.Constants.member_fields() - field_to_hide = List.first(member_fields) - field_to_show = List.last(member_fields) - - # Update settings to hide a field (use string keys for JSONB) - {:ok, _updated_settings} = - Mv.Membership.update_settings(settings, %{ - member_field_visibility: %{Atom.to_string(field_to_hide) => false} - }) - - # JSONB may convert atom keys to string keys, so we check via show_in_overview? instead - assert Member.show_in_overview?(field_to_hide) == false - assert Member.show_in_overview?(field_to_show) == true - end - - test "returns true for non-configured fields (default)" do - # Get or create settings - {:ok, settings} = Mv.Membership.get_settings() - - # Use fields that exist in member fields - member_fields = Mv.Constants.member_fields() - fields_to_hide = Enum.take(member_fields, 2) - fields_to_show = Enum.take(member_fields, -2) - - # Update settings to hide some fields (use string keys for JSONB) - visibility_config = - Enum.reduce(fields_to_hide, %{}, fn field, acc -> - Map.put(acc, Atom.to_string(field), false) - end) - - {:ok, _updated_settings} = - Mv.Membership.update_settings(settings, %{ - member_field_visibility: visibility_config - }) - - # Hidden fields should be false - Enum.each(fields_to_hide, fn field -> - assert Member.show_in_overview?(field) == false, - "Field #{field} should be hidden" - end) - - # Unconfigured fields should still be true (default) - Enum.each(fields_to_show, fn field -> - assert Member.show_in_overview?(field) == true, - "Field #{field} should be visible by default" - end) - end - end end diff --git a/test/mv_web/components/field_visibility_dropdown_component_test.exs b/test/mv_web/components/field_visibility_dropdown_component_test.exs deleted file mode 100644 index 6e01afa..0000000 --- a/test/mv_web/components/field_visibility_dropdown_component_test.exs +++ /dev/null @@ -1,21 +0,0 @@ -defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do - use MvWeb.ConnCase, async: true - import Phoenix.LiveViewTest - - describe "field visibility dropdown in member view" do - test "renders and toggles visibility", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, ~p"/members") - - # Renders Dropdown - assert has_element?(view, "[data-testid='dropdown-menu']") - - # Opens Dropdown - view |> element("[data-testid='dropdown-button']") |> render_click() - assert has_element?(view, "#field-visibility-menu") - assert has_element?(view, "button[phx-click='select_item'][phx-value-item='email']") - assert has_element?(view, "button[phx-click='select_all']") - assert has_element?(view, "button[phx-click='select_none']") - end - end -end diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs index e199635..2e6d4fe 100644 --- a/test/mv_web/components/sort_header_component_test.exs +++ b/test/mv_web/components/sort_header_component_test.exs @@ -150,27 +150,35 @@ defmodule MvWeb.Components.SortHeaderComponentTest do assert has_element?(view, "[data-testid='email'] .opacity-40") end - test "icon distribution shows exactly one active sort icon", %{conn: conn} do + test "icon distribution is correct for all fields", %{conn: conn} do conn = conn_with_oidc_user(conn) - # Test neutral state - only one field should have active sort icon + # Test neutral state - all fields except first name (default) should show neutral icons {:ok, _view, html_neutral} = live(conn, "/members") - # Count active icons (should be exactly 1 - ascending for default sort field) + # Count neutral icons (should be 7 - one for each field) + neutral_count = + html_neutral |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1) + + assert neutral_count == 7 + + # Count active icons (should be 1) up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1) down_count = html_neutral |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1) + assert up_count == 1 + assert down_count == 0 - assert up_count == 1, "Expected exactly 1 ascending icon, got #{up_count}" - assert down_count == 0, "Expected 0 descending icons, got #{down_count}" + # Test ascending state - one field active, others neutral + {:ok, _view, html_asc} = live(conn, "/members?sort_field=first_name&sort_order=asc") - # Test descending state - {:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc") + # Should have exactly 1 ascending icon and 7 neutral icons + up_count = html_asc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1) + neutral_count = html_asc |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1) + down_count = html_asc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1) - up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1) - down_count = html_desc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1) - - assert up_count == 0, "Expected 0 ascending icons, got #{up_count}" - assert down_count == 1, "Expected exactly 1 descending icon, got #{down_count}" + assert up_count == 1 + assert neutral_count == 7 + assert down_count == 0 end end diff --git a/test/mv_web/live/custom_field_live/deletion_test.exs b/test/mv_web/live/custom_field_live/deletion_test.exs index 322cf38..f0317e0 100644 --- a/test/mv_web/live/custom_field_live/deletion_test.exs +++ b/test/mv_web/live/custom_field_live/deletion_test.exs @@ -1,7 +1,6 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do @moduledoc """ - Tests for CustomFieldLive.IndexComponent deletion modal and slug confirmation. - Tests the custom field management component embedded in the settings page. + Tests for CustomFieldLive.Index deletion modal and slug confirmation. Tests cover: - Opening deletion confirmation modal @@ -40,11 +39,11 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do # Create custom field value create_custom_field_value(member, custom_field, "test") - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/custom_fields") - # Click delete button - find the delete link within the component + # Click delete button view - |> element("#custom-fields-component a", "Delete") + |> element("a", "Delete") |> render_click() # Modal should be visible @@ -66,10 +65,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do create_custom_field_value(member1, custom_field, "test1") create_custom_field_value(member2, custom_field, "test2") - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/custom_fields") view - |> element("#custom-fields-component a", "Delete") + |> element("a", "Delete") |> render_click() # Should show plural form @@ -79,10 +78,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do test "shows 0 members for custom field without values", %{conn: conn} do {:ok, _custom_field} = create_custom_field("test_field", :string) - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/custom_fields") view - |> element("#custom-fields-component a", "Delete") + |> element("a", "Delete") |> render_click() # Should show 0 members @@ -94,16 +93,15 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do test "updates confirmation state when typing", %{conn: conn} do {:ok, custom_field} = create_custom_field("test_field", :string) - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/custom_fields") view - |> element("#custom-fields-component a", "Delete") + |> element("a", "Delete") |> render_click() - # Type in slug input - use element to find the form with phx-target + # Type in slug input view - |> element("#delete-custom-field-modal form") - |> render_change(%{"slug" => custom_field.slug}) + |> render_change("update_slug_confirmation", %{"slug" => custom_field.slug}) # Confirm button should be enabled now (no disabled attribute) html = render(view) @@ -113,16 +111,15 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do test "delete button is disabled when slug doesn't match", %{conn: conn} do {:ok, _custom_field} = create_custom_field("test_field", :string) - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/custom_fields") view - |> element("#custom-fields-component a", "Delete") + |> element("a", "Delete") |> render_click() - # Type wrong slug - use element to find the form with phx-target + # Type wrong slug view - |> element("#delete-custom-field-modal form") - |> render_change(%{"slug" => "wrong-slug"}) + |> render_change("update_slug_confirmation", %{"slug" => "wrong-slug"}) # Button should be disabled html = render(view) @@ -136,21 +133,20 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do {:ok, custom_field} = create_custom_field("test_field", :string) {:ok, custom_field_value} = create_custom_field_value(member, custom_field, "test") - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/custom_fields") # Open modal view - |> element("#custom-fields-component a", "Delete") + |> element("a", "Delete") |> render_click() - # Enter correct slug - use element to find the form with phx-target + # Enter correct slug view - |> element("#delete-custom-field-modal form") - |> render_change(%{"slug" => custom_field.slug}) + |> render_change("update_slug_confirmation", %{"slug" => custom_field.slug}) # Click confirm view - |> element("#delete-custom-field-modal button", "Delete Custom Field and All Values") + |> element("button", "Delete Custom Field and All Values") |> render_click() # Should show success message @@ -166,28 +162,27 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do assert {:ok, _} = Ash.get(Member, member.id) end - test "button remains disabled and custom field not deleted when slug doesn't match", %{ - conn: conn - } do + test "shows error when slug doesn't match", %{conn: conn} do {:ok, custom_field} = create_custom_field("test_field", :string) - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/custom_fields") view - |> element("#custom-fields-component a", "Delete") + |> element("a", "Delete") |> render_click() - # Enter wrong slug - use element to find the form with phx-target + # Enter wrong slug view - |> element("#delete-custom-field-modal form") - |> render_change(%{"slug" => "wrong-slug"}) + |> render_change("update_slug_confirmation", %{"slug" => "wrong-slug"}) - # Button should be disabled and we cannot click it - # The test verifies that the button is properly disabled in the UI - html = render(view) - assert html =~ ~r/disabled(?:=""|(?!\w))/ + # Try to confirm (button should be disabled, but test the handler anyway) + view + |> render_click("confirm_delete", %{}) - # Custom field should still exist since deletion couldn't proceed + # Should show error message + assert render(view) =~ "Slug does not match" + + # Custom field should still exist assert {:ok, _} = Ash.get(CustomField, custom_field.id) end end @@ -196,10 +191,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do test "closes modal without deleting", %{conn: conn} do {:ok, custom_field} = create_custom_field("test_field", :string) - {:ok, view, _html} = live(conn, ~p"/settings") + {:ok, view, _html} = live(conn, ~p"/custom_fields") view - |> element("#custom-fields-component a", "Delete") + |> element("a", "Delete") |> render_click() # Modal should be visible @@ -207,7 +202,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do # Click cancel view - |> element("#delete-custom-field-modal button", "Cancel") + |> element("button", "Cancel") |> render_click() # Modal should be gone diff --git a/test/mv_web/live/member_live/index/field_selection_test.exs b/test/mv_web/live/member_live/index/field_selection_test.exs deleted file mode 100644 index 9d6aa77..0000000 --- a/test/mv_web/live/member_live/index/field_selection_test.exs +++ /dev/null @@ -1,370 +0,0 @@ -defmodule MvWeb.MemberLive.Index.FieldSelectionTest do - @moduledoc """ - Tests for FieldSelection module handling cookie/session/URL management. - """ - use ExUnit.Case, async: true - - alias MvWeb.MemberLive.Index.FieldSelection - - describe "get_from_session/1" do - test "returns empty map when session is empty" do - assert FieldSelection.get_from_session(%{}) == %{} - end - - test "returns empty map when session key is missing" do - session = %{"other_key" => "value"} - assert FieldSelection.get_from_session(session) == %{} - end - - test "parses valid JSON from session" do - json = Jason.encode!(%{"first_name" => true, "email" => false}) - session = %{"member_field_selection" => json} - - result = FieldSelection.get_from_session(session) - - assert result == %{"first_name" => true, "email" => false} - end - - test "handles invalid JSON gracefully" do - session = %{"member_field_selection" => "invalid json{["} - - result = FieldSelection.get_from_session(session) - - assert result == %{} - end - - test "converts non-boolean values to true" do - json = Jason.encode!(%{"first_name" => "true", "email" => 1, "street" => true}) - session = %{"member_field_selection" => json} - - result = FieldSelection.get_from_session(session) - - # All values should be booleans, non-booleans default to true - assert result["first_name"] == true - assert result["email"] == true - assert result["street"] == true - end - - test "handles nil session" do - assert FieldSelection.get_from_session(nil) == %{} - end - - test "handles non-map session" do - assert FieldSelection.get_from_session("not a map") == %{} - end - end - - describe "save_to_session/2" do - test "saves field selection to session as JSON" do - session = %{} - selection = %{"first_name" => true, "email" => false} - - result = FieldSelection.save_to_session(session, selection) - - assert Map.has_key?(result, "member_field_selection") - assert Jason.decode!(result["member_field_selection"]) == selection - end - - test "overwrites existing selection" do - session = %{"member_field_selection" => Jason.encode!(%{"old" => true})} - selection = %{"new" => true} - - result = FieldSelection.save_to_session(session, selection) - - assert Jason.decode!(result["member_field_selection"]) == selection - end - - test "handles empty selection" do - session = %{} - selection = %{} - - result = FieldSelection.save_to_session(session, selection) - - assert Jason.decode!(result["member_field_selection"]) == %{} - end - - test "handles invalid selection gracefully" do - session = %{} - - result = FieldSelection.save_to_session(session, "not a map") - - assert result == session - end - end - - describe "get_from_cookie/1" do - test "returns empty map when cookie header is missing" do - conn = %Plug.Conn{} - - result = FieldSelection.get_from_cookie(conn) - - assert result == %{} - end - - test "returns empty map when cookie is empty string" do - conn = Plug.Conn.put_req_header(%Plug.Conn{}, "cookie", "") - - result = FieldSelection.get_from_cookie(conn) - - assert result == %{} - end - - test "parses valid JSON from cookie" do - selection = %{"first_name" => true, "email" => false} - cookie_value = selection |> Jason.encode!() |> URI.encode() - cookie_header = "member_field_selection=#{cookie_value}" - conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header) - - result = FieldSelection.get_from_cookie(conn) - - assert result == selection - end - - test "handles invalid JSON in cookie gracefully" do - cookie_value = URI.encode("invalid{[") - cookie_header = "member_field_selection=#{cookie_value}" - conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header) - - result = FieldSelection.get_from_cookie(conn) - - assert result == %{} - end - - test "handles cookie with other values" do - selection = %{"street" => true} - cookie_value = selection |> Jason.encode!() |> URI.encode() - cookie_header = "other_cookie=value; member_field_selection=#{cookie_value}; another=test" - conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header) - - result = FieldSelection.get_from_cookie(conn) - - assert result == selection - end - end - - describe "save_to_cookie/2" do - test "saves field selection to cookie" do - conn = %Plug.Conn{} - selection = %{"first_name" => true, "email" => false} - - result = FieldSelection.save_to_cookie(conn, selection) - - # Check that cookie is set - assert result.resp_cookies["member_field_selection"] - cookie = result.resp_cookies["member_field_selection"] - assert cookie[:max_age] == 365 * 24 * 60 * 60 - assert cookie[:same_site] == "Lax" - assert cookie[:http_only] == true - end - - test "handles invalid selection gracefully" do - conn = %Plug.Conn{} - - result = FieldSelection.save_to_cookie(conn, "not a map") - - assert result == conn - end - end - - describe "parse_from_url/1" do - test "returns empty map when params is empty" do - assert FieldSelection.parse_from_url(%{}) == %{} - end - - test "returns empty map when fields parameter is missing" do - params = %{"query" => "test", "sort_field" => "first_name"} - assert FieldSelection.parse_from_url(params) == %{} - end - - test "parses comma-separated field names" do - params = %{"fields" => "first_name,email,street"} - - result = FieldSelection.parse_from_url(params) - - assert result == %{ - "first_name" => true, - "email" => true, - "street" => true - } - end - - test "handles custom field names" do - params = %{"fields" => "custom_field_abc-123,custom_field_def-456"} - - result = FieldSelection.parse_from_url(params) - - assert result == %{ - "custom_field_abc-123" => true, - "custom_field_def-456" => true - } - end - - test "handles mixed member and custom fields" do - params = %{"fields" => "first_name,custom_field_123,email"} - - result = FieldSelection.parse_from_url(params) - - assert result == %{ - "first_name" => true, - "custom_field_123" => true, - "email" => true - } - end - - test "trims whitespace from field names" do - params = %{"fields" => " first_name , email , street "} - - result = FieldSelection.parse_from_url(params) - - assert result == %{ - "first_name" => true, - "email" => true, - "street" => true - } - end - - test "handles empty fields string" do - params = %{"fields" => ""} - assert FieldSelection.parse_from_url(params) == %{} - end - - test "handles nil fields parameter" do - params = %{"fields" => nil} - assert FieldSelection.parse_from_url(params) == %{} - end - - test "filters out empty field names" do - params = %{"fields" => "first_name,,email,"} - - result = FieldSelection.parse_from_url(params) - - assert result == %{ - "first_name" => true, - "email" => true - } - end - - test "handles non-map params" do - assert FieldSelection.parse_from_url(nil) == %{} - assert FieldSelection.parse_from_url("not a map") == %{} - end - end - - describe "merge_sources/3" do - test "merges all sources with URL having highest priority" do - url_selection = %{"first_name" => false} - session_selection = %{"first_name" => true, "email" => true} - cookie_selection = %{"first_name" => true, "street" => true} - - result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection) - - # URL overrides session, session overrides cookie - assert result["first_name"] == false - assert result["email"] == true - assert result["street"] == true - end - - test "handles empty sources" do - result = FieldSelection.merge_sources(%{}, %{}, %{}) - - assert result == %{} - end - - test "cookie only" do - cookie_selection = %{"first_name" => true} - - result = FieldSelection.merge_sources(%{}, %{}, cookie_selection) - - assert result == %{"first_name" => true} - end - - test "session overrides cookie" do - session_selection = %{"first_name" => false} - cookie_selection = %{"first_name" => true} - - result = FieldSelection.merge_sources(%{}, session_selection, cookie_selection) - - assert result["first_name"] == false - end - - test "URL overrides everything" do - url_selection = %{"first_name" => true} - session_selection = %{"first_name" => false} - cookie_selection = %{"first_name" => false} - - result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection) - - assert result["first_name"] == true - end - - test "combines fields from all sources" do - url_selection = %{"url_field" => true} - session_selection = %{"session_field" => true} - cookie_selection = %{"cookie_field" => true} - - result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection) - - assert result["url_field"] == true - assert result["session_field"] == true - assert result["cookie_field"] == true - end - end - - describe "to_url_param/1" do - test "converts selection to comma-separated string" do - selection = %{"first_name" => true, "email" => true, "street" => false} - - result = FieldSelection.to_url_param(selection) - - # Only visible fields should be included (order may vary) - fields = String.split(result, ",") |> Enum.sort() - assert fields == ["email", "first_name"] - end - - test "handles empty selection" do - assert FieldSelection.to_url_param(%{}) == "" - end - - test "handles all fields hidden" do - selection = %{"first_name" => false, "email" => false} - - result = FieldSelection.to_url_param(selection) - - assert result == "" - end - - test "preserves field order" do - selection = %{ - "z_field" => true, - "a_field" => true, - "m_field" => true - } - - result = FieldSelection.to_url_param(selection) - - # Order should be preserved (map iteration order) - assert String.contains?(result, "z_field") - assert String.contains?(result, "a_field") - assert String.contains?(result, "m_field") - end - - test "handles custom fields" do - selection = %{ - "first_name" => true, - "custom_field_abc-123" => true, - "email" => false - } - - result = FieldSelection.to_url_param(selection) - - assert String.contains?(result, "first_name") - assert String.contains?(result, "custom_field_abc-123") - refute String.contains?(result, "email") - end - - test "handles invalid input" do - assert FieldSelection.to_url_param(nil) == "" - assert FieldSelection.to_url_param("not a map") == "" - end - end -end diff --git a/test/mv_web/live/member_live/index/field_visibility_test.exs b/test/mv_web/live/member_live/index/field_visibility_test.exs deleted file mode 100644 index 83ae06d..0000000 --- a/test/mv_web/live/member_live/index/field_visibility_test.exs +++ /dev/null @@ -1,336 +0,0 @@ -defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do - @moduledoc """ - Tests for FieldVisibility module handling field visibility merging logic. - """ - use ExUnit.Case, async: true - - alias MvWeb.MemberLive.Index.FieldVisibility - - # Mock custom field structs for testing - defp create_custom_field(id, name, show_in_overview \\ true) do - %{ - id: id, - name: name, - show_in_overview: show_in_overview - } - end - - describe "get_all_available_fields/1" do - test "returns member fields and custom fields" do - custom_fields = [ - create_custom_field("cf1", "Custom Field 1"), - create_custom_field("cf2", "Custom Field 2") - ] - - result = FieldVisibility.get_all_available_fields(custom_fields) - - # Should include all member fields - assert :first_name in result - assert :email in result - assert :street in result - - # Should include custom fields as strings - assert "custom_field_cf1" in result - assert "custom_field_cf2" in result - end - - test "handles empty custom fields list" do - result = FieldVisibility.get_all_available_fields([]) - - # Should only have member fields - assert :first_name in result - assert :email in result - - refute Enum.any?(result, fn field -> - is_binary(field) and String.starts_with?(field, "custom_field_") - end) - end - - test "includes all member fields from constants" do - custom_fields = [] - result = FieldVisibility.get_all_available_fields(custom_fields) - - member_fields = Mv.Constants.member_fields() - - Enum.each(member_fields, fn field -> - assert field in result - end) - end - end - - describe "merge_with_global_settings/3" do - test "user selection overrides global settings" do - user_selection = %{"first_name" => false} - settings = %{member_field_visibility: %{first_name: true, email: true}} - custom_fields = [] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - assert result["first_name"] == false - assert result["email"] == true - end - - test "falls back to global settings when user selection is empty" do - user_selection = %{} - settings = %{member_field_visibility: %{first_name: false, email: true}} - custom_fields = [] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - assert result["first_name"] == false - assert result["email"] == true - end - - test "defaults to true when field not in settings" do - user_selection = %{} - settings = %{member_field_visibility: %{first_name: false}} - custom_fields = [] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - # first_name from settings - assert result["first_name"] == false - # email defaults to true (not in settings) - assert result["email"] == true - end - - test "handles custom fields visibility" do - user_selection = %{} - settings = %{member_field_visibility: %{}} - - custom_fields = [ - create_custom_field("cf1", "Custom 1", true), - create_custom_field("cf2", "Custom 2", false) - ] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - assert result["custom_field_cf1"] == true - assert result["custom_field_cf2"] == false - end - - test "user selection overrides custom field visibility" do - user_selection = %{"custom_field_cf1" => false} - settings = %{member_field_visibility: %{}} - - custom_fields = [ - create_custom_field("cf1", "Custom 1", true) - ] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - assert result["custom_field_cf1"] == false - end - - test "handles string keys in settings (JSONB format)" do - user_selection = %{} - settings = %{member_field_visibility: %{"first_name" => false, "email" => true}} - custom_fields = [] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - assert result["first_name"] == false - assert result["email"] == true - end - - test "handles mixed atom and string keys in settings" do - user_selection = %{} - # Use string keys only (as JSONB would return) - settings = %{member_field_visibility: %{"first_name" => false, "email" => true}} - custom_fields = [] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - assert result["first_name"] == false - assert result["email"] == true - end - - test "handles nil settings gracefully" do - user_selection = %{} - settings = %{member_field_visibility: nil} - custom_fields = [] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - # Should default all fields to true - assert result["first_name"] == true - assert result["email"] == true - end - - test "handles missing member_field_visibility key" do - user_selection = %{} - settings = %{} - custom_fields = [] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - # Should default all fields to true - assert result["first_name"] == true - assert result["email"] == true - end - - test "includes all fields in result" do - user_selection = %{"first_name" => false} - settings = %{member_field_visibility: %{email: true}} - - custom_fields = [ - create_custom_field("cf1", "Custom 1", true) - ] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - # Should include all member fields - member_fields = Mv.Constants.member_fields() - - Enum.each(member_fields, fn field -> - assert Map.has_key?(result, Atom.to_string(field)) - end) - - # Should include custom fields - assert Map.has_key?(result, "custom_field_cf1") - end - end - - describe "get_visible_fields/1" do - test "returns only fields with true visibility" do - selection = %{ - "first_name" => true, - "email" => false, - "street" => true, - "custom_field_123" => false - } - - result = FieldVisibility.get_visible_fields(selection) - - assert :first_name in result - assert :street in result - refute :email in result - refute "custom_field_123" in result - end - - test "converts member field strings to atoms" do - selection = %{"first_name" => true, "email" => true} - - result = FieldVisibility.get_visible_fields(selection) - - assert :first_name in result - assert :email in result - end - - test "keeps custom fields as strings" do - selection = %{"custom_field_abc-123" => true} - - result = FieldVisibility.get_visible_fields(selection) - - assert "custom_field_abc-123" in result - end - - test "handles empty selection" do - assert FieldVisibility.get_visible_fields(%{}) == [] - end - - test "handles all fields hidden" do - selection = %{"first_name" => false, "email" => false} - - assert FieldVisibility.get_visible_fields(selection) == [] - end - - test "handles invalid input" do - assert FieldVisibility.get_visible_fields(nil) == [] - end - end - - describe "get_visible_member_fields/1" do - test "returns only member fields that are visible" do - selection = %{ - "first_name" => true, - "email" => true, - "custom_field_123" => true, - "street" => false - } - - result = FieldVisibility.get_visible_member_fields(selection) - - assert :first_name in result - assert :email in result - refute :street in result - refute "custom_field_123" in result - end - - test "filters out custom fields" do - selection = %{ - "first_name" => true, - "custom_field_123" => true, - "custom_field_456" => true - } - - result = FieldVisibility.get_visible_member_fields(selection) - - assert :first_name in result - refute "custom_field_123" in result - refute "custom_field_456" in result - end - - test "handles empty selection" do - assert FieldVisibility.get_visible_member_fields(%{}) == [] - end - - test "handles invalid input" do - assert FieldVisibility.get_visible_member_fields(nil) == [] - end - end - - describe "get_visible_custom_fields/1" do - test "returns only custom fields that are visible" do - selection = %{ - "first_name" => true, - "custom_field_123" => true, - "custom_field_456" => false, - "email" => true - } - - result = FieldVisibility.get_visible_custom_fields(selection) - - assert "custom_field_123" in result - refute "custom_field_456" in result - refute :first_name in result - refute :email in result - end - - test "filters out member fields" do - selection = %{ - "first_name" => true, - "email" => true, - "custom_field_123" => true - } - - result = FieldVisibility.get_visible_custom_fields(selection) - - assert "custom_field_123" in result - refute :first_name in result - refute :email in result - end - - test "handles empty selection" do - assert FieldVisibility.get_visible_custom_fields(%{}) == [] - end - - test "handles fields that look like custom fields but aren't" do - selection = %{ - "custom_field_123" => true, - "custom_field_like_name" => true, - "not_custom_field" => true - } - - result = FieldVisibility.get_visible_custom_fields(selection) - - assert "custom_field_123" in result - assert "custom_field_like_name" in result - refute "not_custom_field" in result - end - - test "handles invalid input" do - assert FieldVisibility.get_visible_custom_fields(nil) == [] - end - end -end diff --git a/test/mv_web/live/profile_navigation_test.exs b/test/mv_web/live/profile_navigation_test.exs index 4b383c6..3222825 100644 --- a/test/mv_web/live/profile_navigation_test.exs +++ b/test/mv_web/live/profile_navigation_test.exs @@ -90,6 +90,8 @@ defmodule MvWeb.ProfileNavigationTest do # Verify we're on the correct profile page with OIDC specific information {:ok, _profile_view, html} = live(conn, "/users/#{user.id}") assert html =~ to_string(user.email) + # OIDC ID should be visible + assert html =~ "oidc_123" # Password auth should be disabled for OIDC users assert html =~ "Not enabled" end @@ -148,6 +150,8 @@ defmodule MvWeb.ProfileNavigationTest do "/members/new", "/custom_field_values", "/custom_field_values/new", + "/custom_fields", + "/custom_fields/new", "/users", "/users/new" ] diff --git a/test/mv_web/member_live/index_custom_fields_display_test.exs b/test/mv_web/member_live/index_custom_fields_display_test.exs index b720099..0485f5e 100644 --- a/test/mv_web/member_live/index_custom_fields_display_test.exs +++ b/test/mv_web/member_live/index_custom_fields_display_test.exs @@ -231,8 +231,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members") - # Date should be displayed in European format (dd.mm.yyyy) - assert html =~ "15.05.1990" + # Date should be displayed in readable format + assert html =~ "1990" or html =~ "1990-05-15" or html =~ "15.05.1990" end test "formats email custom field values correctly", %{conn: conn, member1: _member1} do @@ -242,7 +242,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do assert html =~ "alice.private@example.com" end - test "shows empty cell for members without custom field values", %{ + test "shows empty cell or placeholder for members without custom field values", %{ conn: conn, member2: _member2, field_show_string: field @@ -253,14 +253,11 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do # The custom field column should exist assert html =~ field.name - # Member2 should exist in the table (first_name and last_name are in separate columns) - assert html =~ "Bob" - assert html =~ "Brown" - - # The value from member1 should appear (phone number) + # Member2 should have an empty cell for this field + # We check that member2's row exists but doesn't have the value + assert html =~ "Bob Brown" + # The value should not appear for member2 (only for member1) + # We check that the value appears somewhere (for member1) but member2 row should have "-" assert html =~ "+49123456789" - - # Note: Member2 doesn't have this custom field value, so the cell is empty - # The implementation shows "" for missing values, which is the expected behavior end end diff --git a/test/mv_web/member_live/index_field_visibility_test.exs b/test/mv_web/member_live/index_field_visibility_test.exs deleted file mode 100644 index 6e1642a..0000000 --- a/test/mv_web/member_live/index_field_visibility_test.exs +++ /dev/null @@ -1,452 +0,0 @@ -defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do - @moduledoc """ - Integration tests for field visibility dropdown functionality. - - Tests cover: - - Field selection dropdown rendering - - Toggling field visibility - - URL parameter persistence - - Select all / deselect all - - Integration with member list display - - Custom fields visibility - """ - use MvWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - require Ash.Query - - alias Mv.Membership.{CustomField, CustomFieldValue, Member} - - setup do - # Create test members - {:ok, member1} = - Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Alice", - last_name: "Anderson", - email: "alice@example.com", - street: "Main St", - city: "Berlin" - }) - |> Ash.create() - - {:ok, member2} = - Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Bob", - last_name: "Brown", - email: "bob@example.com", - street: "Second St", - city: "Hamburg" - }) - |> Ash.create() - - # Create custom field - {:ok, custom_field} = - CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "membership_number", - value_type: :string, - show_in_overview: true - }) - |> Ash.create() - - # Create custom field values - {:ok, _cfv1} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: custom_field.id, - value: "M001" - }) - |> Ash.create() - - {:ok, _cfv2} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member2.id, - custom_field_id: custom_field.id, - value: "M002" - }) - |> Ash.create() - - %{ - member1: member1, - member2: member2, - custom_field: custom_field - } - end - - describe "field visibility dropdown" do - test "renders dropdown button", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members") - - assert html =~ "Columns" - assert html =~ ~s(aria-controls="field-visibility-menu") - end - - test "opens dropdown when button is clicked", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Initially closed - refute has_element?(view, "ul#field-visibility-menu") - - # Click button - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - # Should be open now - assert has_element?(view, "ul#field-visibility-menu") - end - - test "displays all member fields in dropdown", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - html = render(view) - - # Check for member fields (formatted labels) - assert html =~ "First Name" or html =~ "first_name" - assert html =~ "Email" or html =~ "email" - assert html =~ "Street" or html =~ "street" - end - - test "displays custom fields in dropdown", %{conn: conn, custom_field: custom_field} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - html = render(view) - - assert html =~ custom_field.name - end - end - - describe "field visibility toggling" do - test "hiding a field removes it from display", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Verify email is visible initially - html = render(view) - assert html =~ "alice@example.com" - - # Open dropdown and hide email - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - view - |> element("button[phx-click='select_item'][phx-value-item='email']") - |> render_click() - - # Wait for update - :timer.sleep(100) - - # Email should no longer be visible - html = render(view) - refute html =~ "alice@example.com" - refute html =~ "bob@example.com" - end - - test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Verify custom field is visible initially - html = render(view) - assert html =~ "M001" or html =~ custom_field.name - - # Open dropdown and hide custom field - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - custom_field_id = custom_field.id - custom_field_string = "custom_field_#{custom_field_id}" - - view - |> element("button[phx-click='select_item'][phx-value-item='#{custom_field_string}']") - |> render_click() - - # Wait for update - :timer.sleep(100) - - # Custom field should no longer be visible - html = render(view) - refute html =~ "M001" - refute html =~ "M002" - end - end - - describe "select all / deselect all" do - test "select all makes all fields visible", %{conn: conn} do - conn = conn_with_oidc_user(conn) - - # Start with some fields hidden - {:ok, view, _html} = live(conn, "/members?fields=first_name") - - # Open dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - # Click select all - view - |> element("button[phx-click='select_all']") - |> render_click() - - # Wait for update - :timer.sleep(100) - - # All fields should be visible - html = render(view) - assert html =~ "alice@example.com" - assert html =~ "Main St" - assert html =~ "Berlin" - end - - test "deselect all hides all fields except first_name", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - # Click deselect all - view - |> element("button[phx-click='select_none']") - |> render_click() - - # Wait for update - :timer.sleep(100) - - # Only first_name should be visible (it's always shown) - html = render(view) - # Email and street should be hidden - refute html =~ "alice@example.com" - refute html =~ "Main St" - end - end - - describe "URL parameter persistence" do - test "field selection is persisted in URL", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown and hide email - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - view - |> element("button[phx-click='select_item'][phx-value-item='email']") - |> render_click() - - # Wait for URL update - :timer.sleep(100) - - # Check that URL contains fields parameter - # Note: In LiveView tests, we check the rendered HTML for the updated state - # The actual URL update happens via push_patch - end - - test "loading page with fields parameter applies selection", %{conn: conn} do - conn = conn_with_oidc_user(conn) - - # Load with first_name and city explicitly set in URL - # Note: Other fields may still be visible due to global settings - {:ok, view, _html} = live(conn, "/members?fields=first_name,city") - - html = render(view) - - # first_name and city should be visible - assert html =~ "Alice" - assert html =~ "Berlin" - - # Note: email and street may still be visible if global settings allow it - # This test verifies that the URL parameters work, not that they hide other fields - end - - test "fields parameter works with custom fields", %{conn: conn, custom_field: custom_field} do - conn = conn_with_oidc_user(conn) - custom_field_id = custom_field.id - - # Load with custom field visible - {:ok, view, _html} = - live(conn, "/members?fields=first_name,custom_field_#{custom_field_id}") - - html = render(view) - - # Custom field should be visible - assert html =~ "M001" or html =~ custom_field.name - end - end - - describe "integration with global settings" do - test "respects global settings when no user selection", %{conn: conn} do - # This test would require setting up global settings - # For now, we verify that the system works with default settings - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members") - - # All fields should be visible by default - assert html =~ "alice@example.com" - assert html =~ "Main St" - end - - test "user selection overrides global settings", %{conn: conn} do - # This would require setting up global settings first - # Then verifying that user selection takes precedence - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Hide a field via dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - view - |> element("button[phx-click='select_item'][phx-value-item='email']") - |> render_click() - - :timer.sleep(100) - - html = render(view) - refute html =~ "alice@example.com" - end - end - - describe "edge cases" do - test "handles empty fields parameter", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members?fields=") - - # Should fall back to global settings - assert html =~ "alice@example.com" - end - - test "handles invalid field names in URL", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid") - - # Should ignore invalid fields and use defaults - assert html =~ "alice@example.com" - end - - test "handles custom field that doesn't exist", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members?fields=first_name,custom_field_nonexistent") - - # Should work without errors - assert html =~ "Alice" - end - - test "handles rapid toggling", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - # Rapidly toggle a field multiple times - for _ <- 1..5 do - view - |> element("button[phx-click='select_item'][phx-value-item='email']") - |> render_click() - - :timer.sleep(50) - end - - # Should still work correctly - html = render(view) - assert html =~ "Alice" - end - end - - describe "accessibility" do - test "dropdown has proper ARIA attributes", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members") - - assert html =~ ~s(aria-controls="field-visibility-menu") - assert html =~ ~s(aria-haspopup="menu") - assert html =~ ~s(role="button") - end - - test "menu items have proper ARIA attributes when open", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - html = render(view) - - assert html =~ ~s(role="menu") - assert html =~ ~s(role="menuitemcheckbox") - assert html =~ ~s(aria-checked) - end - - test "keyboard navigation works", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - # Check that elements are keyboard accessible - html = render(view) - assert html =~ ~s(tabindex="0") - # Check that keyboard events are supported - assert html =~ ~s(phx-keydown="select_item") - assert html =~ ~s(phx-key="Enter") - end - - test "keyboard activation with Enter key works", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Verify email is visible initially - html = render(view) - assert html =~ "alice@example.com" - - # Open dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - # Simulate Enter key press on email field button - view - |> element("button[phx-click='select_item'][phx-value-item='email']") - |> render_keydown(%{key: "Enter"}) - - # Wait for update - :timer.sleep(100) - - # Email should no longer be visible - html = render(view) - refute html =~ "alice@example.com" - end - end -end diff --git a/test/mv_web/user_live/index_test.exs b/test/mv_web/user_live/index_test.exs index 360ef72..c0b0275 100644 --- a/test/mv_web/user_live/index_test.exs +++ b/test/mv_web/user_live/index_test.exs @@ -33,6 +33,8 @@ defmodule MvWeb.UserLive.IndexTest do assert html =~ "alice@example.com" assert html =~ "bob@example.com" + assert html =~ "alice123" + assert html =~ "bob456" end test "shows correct action links", %{conn: conn} do @@ -384,6 +386,10 @@ defmodule MvWeb.UserLive.IndexTest do # Should still show the table structure assert html =~ "Email" + assert html =~ "OIDC ID" + # Should show the authenticated user at minimum + # Matches the generated email pattern oidc.user{unique_id}@example.com + assert html =~ "oidc.user" end test "handles users with missing OIDC ID", %{conn: conn} do diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 853a326..0ee2364 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -123,13 +123,7 @@ defmodule MvWeb.ConnCase do end setup tags do - pid = Mv.DataCase.setup_sandbox(tags) - - conn = Phoenix.ConnTest.build_conn() - # Set metadata for Phoenix.Ecto.SQL.Sandbox plug to allow LiveView processes - # to share the test's database connection in async tests - conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid) - - {:ok, conn: conn} + Mv.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} end end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 4ba75ef..6e53c38 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -34,12 +34,10 @@ defmodule Mv.DataCase do @doc """ Sets up the sandbox based on the test tags. - Returns the owner pid for use with Phoenix.Ecto.SQL.Sandbox. """ def setup_sandbox(tags) do pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Mv.Repo, shared: not tags[:async]) on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) - pid end @doc """