From cc95fcacbfd54b3e2aee5629012ea782bc07fd07 Mon Sep 17 00:00:00 2001
From: Emiliano Frascaro
Date: Thu, 10 Sep 2026 18:18:18 +0200
Subject: [PATCH] =?UTF-8?q?Permette=20all=E2=80=99admin=20di=20forzare=20a?=
=?UTF-8?q?nagrafica,=20fiscali,=20owner,=20staff=20e=20inviti.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ops può correggere dati clienti errati (es. email titolare) dalla scheda società senza interventi manuali sul DB.
Co-authored-by: Cursor
---
.../app/controllers/admin/clubs_controller.rb | 97 ++++++++++-
.../app/services/admin/update_club_data.rb | 156 ++++++++++++++++++
.../admin/clubs/_billing_profile.html.erb | 1 +
backend/app/views/admin/clubs/edit.html.erb | 139 ++++++++++++++++
backend/app/views/admin/clubs/show.html.erb | 2 +
backend/app/views/layouts/admin.html.erb | 2 +-
backend/config/locales/admin.de.yml | 37 +++++
backend/config/locales/admin.en.yml | 37 +++++
backend/config/locales/admin.es.yml | 37 +++++
backend/config/locales/admin.fr.yml | 37 +++++
backend/config/locales/admin.it.yml | 37 +++++
backend/config/routes.rb | 2 +-
backend/public/admin.css | 13 ++
.../spec/requests/admin/clubs_edit_spec.rb | 104 ++++++++++++
.../services/admin/update_club_data_spec.rb | 46 ++++++
15 files changed, 739 insertions(+), 8 deletions(-)
create mode 100644 backend/app/services/admin/update_club_data.rb
create mode 100644 backend/app/views/admin/clubs/edit.html.erb
create mode 100644 backend/spec/requests/admin/clubs_edit_spec.rb
create mode 100644 backend/spec/services/admin/update_club_data_spec.rb
diff --git a/backend/app/controllers/admin/clubs_controller.rb b/backend/app/controllers/admin/clubs_controller.rb
index 2e564ea..8e69829 100644
--- a/backend/app/controllers/admin/clubs_controller.rb
+++ b/backend/app/controllers/admin/clubs_controller.rb
@@ -1,6 +1,6 @@
module Admin
class ClubsController < BaseController
- before_action :set_club, only: %i[show grant_comped revoke_comped set_quote revoke_quote]
+ before_action :set_club, only: %i[show edit update grant_comped revoke_comped set_quote revoke_quote]
def index
@clubs = Club.includes(:teams, :billing_quote, { club_memberships: :user }, subscription: %i[plan admin_comped_by])
@@ -8,11 +8,27 @@ module Admin
end
def show
- @subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
- @plans = Plan.ordered.reject { |p| p.slug == "free" }
- @teams = @club.teams.order(:name)
- @quote = @club.active_billing_quote
- @concurrency_violations = StreamConcurrencyViolation.for_club(@club.id).recent.limit(20)
+ load_show_context
+ end
+
+ def edit
+ load_edit_context
+ end
+
+ def update
+ Admin::UpdateClubData.call(
+ club: @club,
+ club_attrs: club_update_params,
+ owner_attrs: owner_params,
+ staff_attrs: staff_params,
+ invitation_attrs: invitation_params,
+ team_attrs: team_params
+ )
+ redirect_to admin_club_path(@club), notice: t("admin.flash.club_updated", club: @club.name)
+ rescue Admin::UpdateClubData::Error, ActiveRecord::RecordInvalid => e
+ flash.now[:alert] = e.message
+ load_edit_context
+ render :edit, status: :unprocessable_entity
end
def grant_comped
@@ -66,6 +82,75 @@ module Admin
@club = Club.includes(club_memberships: :user).find(params[:id])
end
+ def load_show_context
+ @subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
+ @plans = Plan.ordered.reject { |p| p.slug == "free" }
+ @teams = @club.teams.order(:name)
+ @quote = @club.active_billing_quote
+ @concurrency_violations = StreamConcurrencyViolation.for_club(@club.id).recent.limit(20)
+ end
+
+ def load_edit_context
+ @teams = @club.teams.includes(:user_teams, :team_invitations).order(:name)
+ @owner = @club.owner
+ @staff_users = User.joins(:user_teams)
+ .where(user_teams: { team_id: @club.teams.select(:id) })
+ .distinct
+ .order(:email)
+ .to_a
+ @pending_invitations = TeamInvitation.pending
+ .where(team_id: @club.teams.select(:id))
+ .includes(:team)
+ .order(:email)
+ @sport_options = Sports::Catalog.as_api_list.map { |entry| [entry[:label], entry[:key]] }
+ end
+
+ def club_update_params
+ params.require(:club).permit(
+ :name, :sport, :logo_url, :primary_color, :secondary_color,
+ :billing_entity_type, :billing_legal_name, :billing_vat_number, :billing_fiscal_code,
+ :billing_email, :billing_phone, :billing_address_line, :billing_city, :billing_province,
+ :billing_postal_code, :billing_country, :billing_recipient_code, :billing_pec
+ )
+ end
+
+ def owner_params
+ params.fetch(:owner, {}).permit(:name, :email).to_h.symbolize_keys
+ end
+
+ def staff_params
+ raw = params[:staff_users]
+ return {} if raw.blank?
+
+ raw.permit!.to_h.each_with_object({}) do |(user_id, attrs), acc|
+ next unless attrs.is_a?(Hash)
+
+ acc[user_id] = attrs.slice("name", "email").symbolize_keys
+ end
+ end
+
+ def invitation_params
+ raw = params[:invitations]
+ return {} if raw.blank?
+
+ raw.permit!.to_h.each_with_object({}) do |(invitation_id, attrs), acc|
+ next unless attrs.is_a?(Hash)
+
+ acc[invitation_id] = attrs.slice("email").symbolize_keys
+ end
+ end
+
+ def team_params
+ raw = params[:teams]
+ return {} if raw.blank?
+
+ raw.permit!.to_h.each_with_object({}) do |(team_id, attrs), acc|
+ next unless attrs.is_a?(Hash)
+
+ acc[team_id] = attrs.slice("name", "sport").symbolize_keys
+ end
+ end
+
def redirect_back_or_club(notice: nil, alert: nil)
target = params[:return_to].presence || admin_club_path(@club)
redirect_to target, notice: notice, alert: alert
diff --git a/backend/app/services/admin/update_club_data.rb b/backend/app/services/admin/update_club_data.rb
new file mode 100644
index 0000000..971070d
--- /dev/null
+++ b/backend/app/services/admin/update_club_data.rb
@@ -0,0 +1,156 @@
+# frozen_string_literal: true
+
+module Admin
+ class UpdateClubData
+ class Error < StandardError; end
+
+ def self.call(**kwargs)
+ new(**kwargs).call
+ end
+
+ def initialize(club:, club_attrs:, owner_attrs: {}, staff_attrs: {}, invitation_attrs: {}, team_attrs: {})
+ @club = club
+ @club_attrs = club_attrs.to_h
+ @owner_attrs = owner_attrs.to_h
+ @staff_attrs = staff_attrs.to_h
+ @invitation_attrs = invitation_attrs.to_h
+ @team_attrs = team_attrs.to_h
+ end
+
+ def call
+ ActiveRecord::Base.transaction do
+ update_club!
+ update_owner!
+ update_staff!
+ update_invitations!
+ update_teams!
+ end
+ @club.reload
+ end
+
+ private
+
+ def update_club!
+ attrs = @club_attrs.dup
+ if attrs[:sport].present?
+ attrs[:sport] = Sports::Catalog.normalize_key(attrs[:sport])
+ end
+ %i[primary_color secondary_color].each do |key|
+ next unless attrs.key?(key)
+
+ attrs[key] = normalize_hex(attrs[key], key == :primary_color ? "#e53935" : "#ffffff")
+ end
+ %i[
+ billing_legal_name billing_vat_number billing_fiscal_code billing_email billing_phone
+ billing_address_line billing_city billing_province billing_postal_code billing_country
+ billing_recipient_code billing_pec logo_url
+ ].each do |key|
+ next unless attrs.key?(key)
+
+ attrs[key] = attrs[key].to_s.strip.presence
+ end
+ if attrs[:billing_province].present?
+ attrs[:billing_province] = attrs[:billing_province].to_s.upcase
+ end
+ if attrs[:billing_country].present?
+ attrs[:billing_country] = attrs[:billing_country].to_s.upcase
+ end
+ if attrs[:billing_recipient_code].present?
+ attrs[:billing_recipient_code] = attrs[:billing_recipient_code].to_s.upcase
+ end
+
+ @club.assign_attributes(attrs)
+ @club.save!
+ rescue ActiveRecord::RecordInvalid => e
+ raise Error, e.record.errors.full_messages.join(", ")
+ end
+
+ def update_owner!
+ return if @owner_attrs.blank?
+
+ owner = @club.owner
+ raise Error, I18n.t("admin.clubs.edit.errors.owner_missing") if owner.blank?
+
+ update_user!(owner, @owner_attrs)
+ end
+
+ def update_staff!
+ return if @staff_attrs.blank?
+
+ allowed_ids = staff_users.index_by(&:id)
+ @staff_attrs.each do |user_id, attrs|
+ user = allowed_ids[user_id.to_s] || allowed_ids[user_id]
+ next unless user
+
+ update_user!(user, attrs)
+ end
+ end
+
+ def update_invitations!
+ return if @invitation_attrs.blank?
+
+ pending = TeamInvitation.pending.where(team_id: @club.teams.select(:id)).index_by { |inv| inv.id.to_s }
+ @invitation_attrs.each do |invitation_id, attrs|
+ invitation = pending[invitation_id.to_s]
+ next unless invitation
+
+ email = attrs[:email].to_s.strip.presence
+ next if email.blank?
+
+ invitation.update!(email: email)
+ rescue ActiveRecord::RecordInvalid => e
+ raise Error, e.record.errors.full_messages.join(", ")
+ end
+ end
+
+ def update_teams!
+ return if @team_attrs.blank?
+
+ teams = @club.teams.index_by { |t| t.id.to_s }
+ @team_attrs.each do |team_id, attrs|
+ team = teams[team_id.to_s]
+ next unless team
+
+ updates = {}
+ updates[:name] = attrs[:name].to_s.strip if attrs.key?(:name) && attrs[:name].present?
+ if attrs[:sport].present?
+ updates[:sport] = Sports::Catalog.normalize_key(attrs[:sport])
+ end
+ next if updates.empty?
+
+ team.update!(updates)
+ rescue ActiveRecord::RecordInvalid => e
+ raise Error, e.record.errors.full_messages.join(", ")
+ end
+ end
+
+ def update_user!(user, attrs)
+ updates = {}
+ updates[:name] = attrs[:name].to_s.strip if attrs.key?(:name) && attrs[:name].present?
+ if attrs.key?(:email)
+ email = attrs[:email].to_s.strip.presence
+ updates[:email] = email if email.present?
+ end
+ return if updates.empty?
+
+ user.update!(updates)
+ rescue ActiveRecord::RecordInvalid => e
+ raise Error, e.record.errors.full_messages.join(", ")
+ rescue ActiveRecord::RecordNotUnique
+ raise Error, I18n.t("admin.clubs.edit.errors.email_taken", email: updates[:email])
+ end
+
+ def staff_users
+ User.joins(:user_teams).where(user_teams: { team_id: @club.teams.select(:id) }).distinct.to_a
+ end
+
+ def normalize_hex(value, fallback)
+ raw = value.to_s.strip
+ return fallback if raw.blank?
+ return raw.downcase if raw.match?(/\A#[0-9a-fA-F]{6}\z/)
+ return "##{raw.downcase}" if raw.match?(/\A[0-9a-fA-F]{6}\z/)
+
+ fallback
+ end
+ end
+end
diff --git a/backend/app/views/admin/clubs/_billing_profile.html.erb b/backend/app/views/admin/clubs/_billing_profile.html.erb
index 67db45a..ca9db7c 100644
--- a/backend/app/views/admin/clubs/_billing_profile.html.erb
+++ b/backend/app/views/admin/clubs/_billing_profile.html.erb
@@ -22,6 +22,7 @@
<%= t("admin.clubs.billing_status.#{status}") %>
+ <%= link_to t("admin.clubs.show.edit_link"), edit_admin_club_path(club), class: "admin-btn admin-btn--sm admin-btn--secondary" %>
<% if status == :absent || lines.empty? %>
diff --git a/backend/app/views/admin/clubs/edit.html.erb b/backend/app/views/admin/clubs/edit.html.erb
new file mode 100644
index 0000000..d332b92
--- /dev/null
+++ b/backend/app/views/admin/clubs/edit.html.erb
@@ -0,0 +1,139 @@
+
+ <%= link_to t("admin.clubs.edit.back"), admin_club_path(@club) %>
+
+
+<%= t("admin.clubs.edit.title", club: @club.name) %>
+<%= t("admin.clubs.edit.lead") %>
+
+<% if flash.now[:alert].present? %>
+ <%= flash.now[:alert] %>
+<% end %>
+
+<%= form_with url: admin_club_path(@club), method: :patch, local: true, class: "admin-form admin-form--wide" do %>
+
+
+
+ <%= t("admin.clubs.edit.sections.billing") %>
+ <%= t("admin.clubs.edit.billing_hint") %>
+ <%= render "shared/billing_profile_fields", record: @club, show_legend: false %>
+
+
+
+ <%= t("admin.clubs.edit.sections.owner") %>
+ <% if @owner %>
+
+ <% else %>
+ <%= t("admin.clubs.show.owner_none") %>
+ <% end %>
+
+
+
+ <%= t("admin.clubs.edit.sections.staff") %>
+ <% if @staff_users.any? %>
+ <%= t("admin.clubs.edit.staff_hint") %>
+ <% @staff_users.each do |user| %>
+
+ <% end %>
+ <% else %>
+ <%= t("admin.clubs.edit.staff_none") %>
+ <% end %>
+
+
+
+ <%= t("admin.clubs.edit.sections.invitations") %>
+ <% if @pending_invitations.any? %>
+ <%= t("admin.clubs.edit.invitations_hint") %>
+ <% @pending_invitations.each do |invitation| %>
+
+ <% end %>
+ <% else %>
+ <%= t("admin.clubs.edit.invitations_none") %>
+ <% end %>
+
+
+
+ <%= t("admin.clubs.edit.sections.teams") %>
+ <% if @teams.any? %>
+ <% @teams.each do |team| %>
+
+ <% end %>
+ <% else %>
+ <%= t("admin.clubs.show.no_teams") %>
+ <% end %>
+
+
+
+ <%= link_to t("admin.clubs.edit.cancel"), admin_club_path(@club), class: "admin-btn admin-btn--secondary" %>
+
+
+<% end %>
diff --git a/backend/app/views/admin/clubs/show.html.erb b/backend/app/views/admin/clubs/show.html.erb
index 47280e9..b6b0ced 100644
--- a/backend/app/views/admin/clubs/show.html.erb
+++ b/backend/app/views/admin/clubs/show.html.erb
@@ -6,10 +6,12 @@
· <%= link_to t("admin.clubs.show.recordings_archive"), admin_club_recordings_path(@club) %>
· <%= link_to t("admin.clubs.show.billing_link"), admin_billing_path(club_id: @club.id) %>
· <%= link_to t("admin.clubs.show.youtube_platform_link"), admin_youtube_platform_path %>
+ · <%= link_to t("admin.clubs.show.edit_link"), edit_admin_club_path(@club) %>
<%= render "admin/clubs/billing_profile", club: @club %>
+
<%= render "admin/clubs/comped_form", club: @club, subscription: @subscription, return_to: admin_club_path(@club) %>
<%= render "admin/clubs/quote_form", club: @club, quote: @quote, return_to: admin_club_path(@club) %>
diff --git a/backend/app/views/layouts/admin.html.erb b/backend/app/views/layouts/admin.html.erb
index 865e86b..57e3928 100644
--- a/backend/app/views/layouts/admin.html.erb
+++ b/backend/app/views/layouts/admin.html.erb
@@ -5,7 +5,7 @@
<%= csrf_meta_tags %>
-
+
<%= yield :head %>
<% if content_for?(:replay_archive_styles) %>
diff --git a/backend/config/locales/admin.de.yml b/backend/config/locales/admin.de.yml
index ad6ec88..803c445 100644
--- a/backend/config/locales/admin.de.yml
+++ b/backend/config/locales/admin.de.yml
@@ -48,6 +48,7 @@ de:
invoice_issued: "Rechnung %{number} ausgestellt und an %{email} gesendet."
invoice_updated: "Rechnung %{number} aktualisiert."
invoice_uploaded: "Rechnung hochgeladen und an %{email} gesendet."
+ club_updated: "Daten für %{club} aktualisiert."
youtube_not_configured: Konfiguriere YOUTUBE_CLIENT_ID und YOUTUBE_CLIENT_SECRET in .env
announcement_created: Hinweis gespeichert
announcement_updated: Hinweis aktualisiert
@@ -274,6 +275,42 @@ de:
concurrency_lead: Dasselbe Konto hat versucht, eine weitere Direktübertragung zu starten, während bereits eine lief.
concurrency_none: Keine Versuche für diesen Verein erfasst.
concurrency_all: Alle Konto-Missbräuche ansehen
+ edit_link: Daten bearbeiten
+ edit:
+ back: "← Zurück zum Verein"
+ title: "%{club} bearbeiten"
+ lead: "Kundendaten überschreiben: Profil, Abrechnung, Inhaber, Staff, Einladungen und Teams."
+ cancel: Abbrechen
+ submit: Änderungen speichern
+ billing_hint: Du kannst ein unvollständiges Profil speichern; der Badge auf der Vereinsseite zeigt den Status.
+ staff_hint: Bereits mit den Teams des Vereins verknüpfte Benutzer.
+ staff_none: Kein Staff mit Teams verknüpft.
+ invitations_hint: Nur noch ausstehende Einladungen.
+ invitations_none: Keine ausstehenden Einladungen.
+ sections:
+ club: Vereinsprofil
+ billing: Rechnungsdaten
+ owner: Kontoinhaber
+ staff: Angenommenes Staff
+ invitations: Ausstehende Einladungen
+ teams: Teams
+ fields:
+ name: Vereinsname
+ sport: Sportart
+ primary_color: Primärfarbe
+ secondary_color: Sekundärfarbe
+ logo_url: Logo-URL
+ owner_name: Name des Inhabers
+ owner_email: E-Mail des Inhabers
+ staff_name: Name
+ staff_email: E-Mail
+ invitation_team: Team
+ invitation_email: Einladungs-E-Mail
+ team_name: Teamname
+ team_sport: Teamsport
+ errors:
+ owner_missing: Kein Inhaber mit diesem Verein verknüpft.
+ email_taken: "E-Mail bereits vergeben: %{email}"
billing_status:
complete: Vollständig
incomplete: Unvollständig
diff --git a/backend/config/locales/admin.en.yml b/backend/config/locales/admin.en.yml
index 2794675..200e616 100644
--- a/backend/config/locales/admin.en.yml
+++ b/backend/config/locales/admin.en.yml
@@ -48,6 +48,7 @@ en:
invoice_issued: "Invoice %{number} issued and sent to %{email}."
invoice_updated: "Invoice %{number} updated."
invoice_uploaded: "Invoice uploaded and sent to %{email}."
+ club_updated: "Data updated for %{club}."
youtube_not_configured: Configure YOUTUBE_CLIENT_ID and YOUTUBE_CLIENT_SECRET in .env
announcement_created: Notice saved
announcement_updated: Notice updated
@@ -274,6 +275,42 @@ en:
concurrency_lead: Same account tried to start another live while one was already running.
concurrency_none: No attempts recorded for this club.
concurrency_all: View all account abuse
+ edit_link: Edit data
+ edit:
+ back: "← Back to club"
+ title: "Edit %{club}"
+ lead: "Override client-entered data: profile, billing, owner, staff, invitations and teams."
+ cancel: Cancel
+ submit: Save changes
+ billing_hint: You can save an incomplete profile; the badge on the club page will reflect the status.
+ staff_hint: Users already linked to this club's teams (including owners if also staff).
+ staff_none: No staff linked to teams.
+ invitations_hint: Only invitations still awaiting acceptance.
+ invitations_none: No pending invitations.
+ sections:
+ club: Club profile
+ billing: Billing details
+ owner: Account owner
+ staff: Accepted staff
+ invitations: Pending invitations
+ teams: Teams
+ fields:
+ name: Club name
+ sport: Sport
+ primary_color: Primary colour
+ secondary_color: Secondary colour
+ logo_url: Logo URL
+ owner_name: Owner name
+ owner_email: Owner email
+ staff_name: Name
+ staff_email: Email
+ invitation_team: Team
+ invitation_email: Invitation email
+ team_name: Team name
+ team_sport: Team sport
+ errors:
+ owner_missing: No owner associated with this club.
+ email_taken: "Email already in use: %{email}"
billing_status:
complete: Complete
incomplete: Incomplete
diff --git a/backend/config/locales/admin.es.yml b/backend/config/locales/admin.es.yml
index 52d578f..075c899 100644
--- a/backend/config/locales/admin.es.yml
+++ b/backend/config/locales/admin.es.yml
@@ -48,6 +48,7 @@ es:
invoice_issued: "Factura %{number} emitida y enviada a %{email}."
invoice_updated: "Factura %{number} actualizada."
invoice_uploaded: "Factura subida y enviada a %{email}."
+ club_updated: "Datos actualizados para %{club}."
youtube_not_configured: Configura YOUTUBE_CLIENT_ID y YOUTUBE_CLIENT_SECRET en .env
announcement_created: Aviso guardado
announcement_updated: Aviso actualizado
@@ -274,6 +275,42 @@ es:
concurrency_lead: La misma cuenta intentó iniciar otro directo mientras ya había uno en curso.
concurrency_none: No hay intentos registrados para este club.
concurrency_all: Ver todos los abusos de cuenta
+ edit_link: Editar datos
+ edit:
+ back: "← Volver al club"
+ title: "Editar %{club}"
+ lead: "Fuerza los datos introducidos por el cliente: perfil, facturación, titular, staff, invitaciones y equipos."
+ cancel: Cancelar
+ submit: Guardar cambios
+ billing_hint: Puedes guardar un perfil incompleto; el badge de la ficha reflejará el estado.
+ staff_hint: Usuarios ya vinculados a los equipos del club.
+ staff_none: No hay staff vinculado a los equipos.
+ invitations_hint: Solo invitaciones pendientes de aceptación.
+ invitations_none: No hay invitaciones pendientes.
+ sections:
+ club: Perfil del club
+ billing: Datos de facturación
+ owner: Titular de la cuenta
+ staff: Staff aceptado
+ invitations: Invitaciones pendientes
+ teams: Equipos
+ fields:
+ name: Nombre del club
+ sport: Deporte
+ primary_color: Color primario
+ secondary_color: Color secundario
+ logo_url: URL del logo
+ owner_name: Nombre del titular
+ owner_email: Correo del titular
+ staff_name: Nombre
+ staff_email: Correo
+ invitation_team: Equipo
+ invitation_email: Correo de invitación
+ team_name: Nombre del equipo
+ team_sport: Deporte del equipo
+ errors:
+ owner_missing: No hay titular asociado a este club.
+ email_taken: "Correo ya en uso: %{email}"
billing_status:
complete: Completo
incomplete: Incompleto
diff --git a/backend/config/locales/admin.fr.yml b/backend/config/locales/admin.fr.yml
index 87a798b..075e781 100644
--- a/backend/config/locales/admin.fr.yml
+++ b/backend/config/locales/admin.fr.yml
@@ -48,6 +48,7 @@ fr:
invoice_issued: "Facture %{number} émise et envoyée à %{email}."
invoice_updated: "Facture %{number} mise à jour."
invoice_uploaded: "Facture chargée et envoyée à %{email}."
+ club_updated: "Données mises à jour pour %{club}."
youtube_not_configured: Configurez YOUTUBE_CLIENT_ID et YOUTUBE_CLIENT_SECRET dans .env
announcement_created: Alerte enregistrée
announcement_updated: Alerte mise à jour
@@ -274,6 +275,42 @@ fr:
concurrency_lead: Le même compte a tenté de démarrer un autre direct alors qu’un était déjà en cours.
concurrency_none: Aucune tentative enregistrée pour ce club.
concurrency_all: Voir tous les abus de compte
+ edit_link: Modifier les données
+ edit:
+ back: "← Retour au club"
+ title: "Modifier %{club}"
+ lead: "Forcez les données saisies par le client : profil, facturation, titulaire, staff, invitations et équipes."
+ cancel: Annuler
+ submit: Enregistrer
+ billing_hint: Vous pouvez enregistrer un profil incomplet ; le badge sur la fiche reflète l'état.
+ staff_hint: Utilisateurs déjà liés aux équipes du club.
+ staff_none: Aucun staff lié aux équipes.
+ invitations_hint: Uniquement les invitations encore en attente d'acceptation.
+ invitations_none: Aucune invitation en attente.
+ sections:
+ club: Profil du club
+ billing: Données de facturation
+ owner: Titulaire du compte
+ staff: Staff accepté
+ invitations: Invitations en attente
+ teams: Équipes
+ fields:
+ name: Nom du club
+ sport: Sport
+ primary_color: Couleur primaire
+ secondary_color: Couleur secondaire
+ logo_url: URL du logo
+ owner_name: Nom du titulaire
+ owner_email: E-mail du titulaire
+ staff_name: Nom
+ staff_email: E-mail
+ invitation_team: Équipe
+ invitation_email: E-mail d'invitation
+ team_name: Nom de l'équipe
+ team_sport: Sport de l'équipe
+ errors:
+ owner_missing: Aucun titulaire associé à ce club.
+ email_taken: "E-mail déjà utilisé : %{email}"
billing_status:
complete: Complet
incomplete: Incomplet
diff --git a/backend/config/locales/admin.it.yml b/backend/config/locales/admin.it.yml
index 507afe8..9505bc4 100644
--- a/backend/config/locales/admin.it.yml
+++ b/backend/config/locales/admin.it.yml
@@ -52,6 +52,7 @@ it:
quote_revoked: "Prezzo concordato revocato per %{club}."
transfer_confirmed: "Bonifico confermato: piano %{plan} attivo per %{club}."
transfer_cancelled: Bonifico in attesa annullato.
+ club_updated: "Dati aggiornati per %{club}."
youtube_not_configured: Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env
announcement_created: Avviso salvato
announcement_updated: Avviso aggiornato
@@ -278,6 +279,42 @@ it:
concurrency_lead: Stesso account che ha provato ad avviare un’altra diretta mentre ne era già in corso una.
concurrency_none: Nessun tentativo registrato per questa società.
concurrency_all: Vedi tutti gli abusi account
+ edit_link: Modifica dati
+ edit:
+ back: "← Torna alla società"
+ title: "Modifica %{club}"
+ lead: "Forza i dati inseriti dal cliente: anagrafica, fatturazione, titolare, staff, inviti e squadre."
+ cancel: Annulla
+ submit: Salva modifiche
+ billing_hint: Puoi salvare anche un profilo incompleto; il badge in scheda rifletterà lo stato.
+ staff_hint: Utenti già collegati alle squadre della società (inclusi eventuali titolari).
+ staff_none: Nessuno staff collegato alle squadre.
+ invitations_hint: Solo inviti ancora in attesa di accettazione.
+ invitations_none: Nessun invito in attesa.
+ sections:
+ club: Anagrafica società
+ billing: Dati di fatturazione
+ owner: Titolare account
+ staff: Staff e invitati accettati
+ invitations: Inviti in attesa
+ teams: Squadre
+ fields:
+ name: Nome società
+ sport: Sport
+ primary_color: Colore primario
+ secondary_color: Colore secondario
+ logo_url: URL logo
+ owner_name: Nome titolare
+ owner_email: Email titolare
+ staff_name: Nome
+ staff_email: Email
+ invitation_team: Squadra
+ invitation_email: Email invito
+ team_name: Nome squadra
+ team_sport: Sport squadra
+ errors:
+ owner_missing: Nessun titolare associato a questa società.
+ email_taken: "Email già in uso: %{email}"
billing_status:
complete: Completo
incomplete: Incompleto
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index 7641089..3a80e70 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -101,7 +101,7 @@ Rails.application.routes.draw do
post "billing/transfers/:id/confirm", to: "billing#confirm_transfer", as: :billing_transfer_confirm
post "billing/transfers/:id/cancel", to: "billing#cancel_transfer", as: :billing_transfer_cancel
resources :teams, only: %i[show]
- resources :clubs, only: %i[index show] do
+ resources :clubs, only: %i[index show edit update] do
member do
post :grant_comped
delete :revoke_comped
diff --git a/backend/public/admin.css b/backend/public/admin.css
index a826d77..937911a 100644
--- a/backend/public/admin.css
+++ b/backend/public/admin.css
@@ -681,6 +681,19 @@ body.admin-body {
max-width: 720px;
}
+.admin-form--wide {
+ max-width: 920px;
+}
+
+.admin-form .panel {
+ max-width: none;
+}
+
+.admin-form .panel h3 {
+ margin: 0 0 0.85rem;
+ font-size: 1rem;
+}
+
.admin-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
diff --git a/backend/spec/requests/admin/clubs_edit_spec.rb b/backend/spec/requests/admin/clubs_edit_spec.rb
new file mode 100644
index 0000000..926acb0
--- /dev/null
+++ b/backend/spec/requests/admin/clubs_edit_spec.rb
@@ -0,0 +1,104 @@
+require "rails_helper"
+
+RSpec.describe "Admin club data edit", type: :request do
+ let!(:admin) { AdminAccount.create!(username: "ops-club-edit", password: "Password123") }
+ let!(:club) do
+ Club.create!(
+ name: "Edit Club",
+ sport: "pallavolo",
+ primary_color: "#e53935",
+ secondary_color: "#ffffff",
+ billing_entity_type: "company",
+ billing_legal_name: "ASD Edit",
+ billing_email: "old-bill@test.it",
+ billing_address_line: "Via 1",
+ billing_city: "Milano",
+ billing_province: "MI",
+ billing_postal_code: "20100",
+ billing_country: "IT",
+ billing_vat_number: "12345678901",
+ billing_recipient_code: "ABCDEFG"
+ )
+ end
+ let!(:owner) do
+ user = User.create!(email: "owner-typo@test.it", name: "Owner Old", password: "Password123", role: "coach")
+ ClubMembership.create!(user: user, club: club, role: "owner")
+ user
+ end
+ let!(:team) { club.teams.create!(name: "U14", sport: "pallavolo") }
+ let!(:staff) do
+ user = User.create!(email: "staff@test.it", name: "Staff Old", password: "Password123", role: "coach")
+ UserTeam.create!(user: user, team: team, role: "member", staff_kind: "transmission")
+ user
+ end
+ let!(:invitation) do
+ token = TeamInvitation.generate_token
+ TeamInvitation.create!(
+ team: team,
+ email: "invite-old@test.it",
+ token_digest: Digest::SHA256.hexdigest(token),
+ role: "member",
+ staff_kind: "transmission",
+ expires_at: 3.days.from_now
+ )
+ end
+
+ before do
+ post admin_login_path, params: { username: "ops-club-edit", password: "Password123" }
+ end
+
+ it "mostra il form di modifica" do
+ get edit_admin_club_path(club)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Modifica Edit Club")
+ expect(response.body).to include("owner-typo@test.it")
+ expect(response.body).to include("invite-old@test.it")
+ end
+
+ it "forza anagrafica, billing, owner, staff, invito e squadra" do
+ patch admin_club_path(club), params: {
+ club: {
+ name: "Edit Club Nuovo",
+ sport: "pallavolo",
+ primary_color: "#112233",
+ secondary_color: "#ffffff",
+ billing_entity_type: "company",
+ billing_legal_name: "ASD Edit Nuova",
+ billing_email: "amministrazione@test.it",
+ billing_vat_number: "12345678901",
+ billing_address_line: "Via Roma 10",
+ billing_city: "Milano",
+ billing_province: "mi",
+ billing_postal_code: "20121",
+ billing_country: "it",
+ billing_recipient_code: "ABCDEFG"
+ },
+ owner: { name: "Owner Fixed", email: "amministrazione@test.it" },
+ staff_users: {
+ staff.id => { name: "Staff Fixed", email: "staff-fixed@test.it" }
+ },
+ invitations: {
+ invitation.id => { email: "invite-fixed@test.it" }
+ },
+ teams: {
+ team.id => { name: "U15", sport: "pallavolo" }
+ }
+ }
+
+ expect(response).to redirect_to(admin_club_path(club))
+ follow_redirect!
+ expect(response.body).to include("Dati aggiornati per Edit Club Nuovo")
+
+ club.reload
+ expect(club.name).to eq("Edit Club Nuovo")
+ expect(club.billing_email).to eq("amministrazione@test.it")
+ expect(club.billing_province).to eq("MI")
+ expect(club.billing_legal_name).to eq("ASD Edit Nuova")
+ expect(owner.reload.email).to eq("amministrazione@test.it")
+ expect(owner.name).to eq("Owner Fixed")
+ expect(staff.reload.email).to eq("staff-fixed@test.it")
+ expect(invitation.reload.email).to eq("invite-fixed@test.it")
+ expect(team.reload.name).to eq("U15")
+ end
+end
diff --git a/backend/spec/services/admin/update_club_data_spec.rb b/backend/spec/services/admin/update_club_data_spec.rb
new file mode 100644
index 0000000..474ac05
--- /dev/null
+++ b/backend/spec/services/admin/update_club_data_spec.rb
@@ -0,0 +1,46 @@
+require "rails_helper"
+
+RSpec.describe Admin::UpdateClubData do
+ let!(:club) do
+ Club.create!(
+ name: "Svc Club",
+ sport: "pallavolo",
+ primary_color: "#e53935",
+ secondary_color: "#ffffff"
+ )
+ end
+ let!(:owner) do
+ user = User.create!(email: "svc-owner@test.it", name: "Owner", password: "Password123", role: "coach")
+ ClubMembership.create!(user: user, club: club, role: "owner")
+ user
+ end
+
+ it "updates club and owner email" do
+ described_class.call(
+ club: club,
+ club_attrs: {
+ name: "Svc Club Updated",
+ billing_email: "bill@test.it",
+ billing_province: "bz"
+ },
+ owner_attrs: { email: "owner-fixed@test.it", name: "Owner Fixed" }
+ )
+
+ expect(club.reload.name).to eq("Svc Club Updated")
+ expect(club.billing_email).to eq("bill@test.it")
+ expect(club.billing_province).to eq("BZ")
+ expect(owner.reload.email).to eq("owner-fixed@test.it")
+ end
+
+ it "raises when email is already taken" do
+ User.create!(email: "taken@test.it", name: "Other", password: "Password123", role: "coach")
+
+ expect do
+ described_class.call(
+ club: club,
+ club_attrs: {},
+ owner_attrs: { email: "taken@test.it" }
+ )
+ end.to raise_error(Admin::UpdateClubData::Error)
+ end
+end