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.club") %>

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+

<%= 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