Compare commits

..
6 Commits
Author SHA1 Message Date
eminuxandCursor 2db6541c91 Fix 500 in cancellazione partita con recording collegato.
Distruggendo la sessione ora si elimina anche il recording, evitando la violazione FK su recordings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 08:09:34 +02:00
eminuxandCursor 5d4bd8544d Aggiunge le cover slate custom generate in ambiente locale.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 18:21:48 +02:00
eminuxandCursor cc95fcacbf Permette all’admin di forzare anagrafica, fiscali, owner, staff e inviti.
Ops può correggere dati clienti errati (es. email titolare) dalla scheda società senza interventi manuali sul DB.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 18:18:18 +02:00
eminuxandCursor d7dd744c32 Mostra in admin anagrafica e dati fiscali delle società.
Così Ops può emettere fattura dalla scheda club senza cercare il profilo solo tra i pagamenti pending.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 16:17:20 +02:00
eminuxandCursor adfea01cdb Permette di filtrare il grafico analytics su una singola pagina.
Aggiunge il filtro Pagina (grafico) e il link Grafico in tabella, mantenendo Da/A/Device.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 22:14:35 +02:00
eminuxandCursor 81e7e3c9af Semplifica il grafico analytics: solo pageview e toggle nascondi.
Chiarisce che il totale è su tutte le pagine (filtri Da/A/Device), distinto dal dettaglio per path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 22:07:34 +02:00
55 changed files with 1335 additions and 88 deletions
@@ -6,7 +6,8 @@ module Admin
@filters = {
from: parse_date(params[:from]) || 7.days.ago.to_date,
to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence
device: params[:device].presence,
chart_path: params[:chart_path].presence
}
@analytics_preview_active = analytics_preview_active?
@@ -37,21 +38,22 @@ module Admin
}
end.sort_by { |r| [-r[:pageviews], -r[:moves], -r[:clicks], r[:page_path]] }
pageviews_by_day = scope.group(:day).sum(:pageview_count)
clicks_by_day = cell_scope.group(:day).sum(:click_count)
moves_by_day = cell_scope.group(:day).sum(:move_count)
@chart_path_options = @pages.map { |row| row[:page_path] }
if @filters[:chart_path].present? && @chart_path_options.exclude?(@filters[:chart_path])
@filters[:chart_path] = nil
end
trend_scope = scope
trend_scope = trend_scope.where(page_path: @filters[:chart_path]) if @filters[:chart_path].present?
pageviews_by_day = trend_scope.group(:day).sum(:pageview_count)
@trend = (@filters[:from]..@filters[:to]).map do |day|
{
day: day.iso8601,
pageviews: pageviews_by_day[day].to_i,
clicks: clicks_by_day[day].to_i,
moves: moves_by_day[day].to_i
pageviews: pageviews_by_day[day].to_i
}
end
@trend_totals = {
pageviews: @trend.sum { |r| r[:pageviews] },
clicks: @trend.sum { |r| r[:clicks] },
moves: @trend.sum { |r| r[:moves] }
pageviews: @trend.sum { |r| r[:pageviews] }
}
end
@@ -1,18 +1,34 @@
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, subscription: %i[plan admin_comped_by])
@clubs = Club.includes(:teams, :billing_quote, { club_memberships: :user }, subscription: %i[plan admin_comped_by])
.order(:name)
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
@@ -63,7 +79,76 @@ module Admin
private
def set_club
@club = Club.find(params[:id])
@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)
+8 -1
View File
@@ -21,7 +21,14 @@ class Club < ApplicationRecord
validates :secondary_color, presence: true
def owner
club_memberships.find_by(role: "owner")&.user
memberships = club_memberships
membership =
if memberships.loaded?
memberships.detect { |m| m.role == "owner" }
else
memberships.find_by(role: "owner")
end
membership&.user
end
def owned_by?(user)
@@ -59,18 +59,43 @@ module ClubBillingProfile
def billing_profile_invoice_lines
lines = []
lines << ["Tipo", self.class.billing_entity_types[billing_entity_type]] if billing_entity_type.present?
lines << ["Intestatario", billing_legal_name]
lines << ["Intestatario", billing_legal_name] if billing_legal_name.present?
lines << ["P.IVA", billing_vat_number] if billing_vat_number.present?
lines << ["Codice fiscale", billing_fiscal_code] if billing_fiscal_code.present?
lines << ["Email fatturazione", billing_email]
lines << ["Email fatturazione", billing_email] if billing_email.present?
lines << ["Telefono", billing_phone] if billing_phone.present?
addr = [billing_address_line, billing_postal_code, billing_city, billing_province, billing_country].compact.join(", ")
lines << ["Indirizzo", addr] if addr.present?
core_address = [billing_address_line, billing_postal_code, billing_city, billing_province].compact_blank
if core_address.any?
core_address << billing_country if billing_country.present?
lines << ["Indirizzo", core_address.join(", ")]
end
lines << ["SDI", billing_recipient_code] if billing_recipient_code.present?
lines << ["PEC", billing_pec] if billing_pec.present?
lines
end
# Stato profilo fiscale per badge admin: :complete | :incomplete | :absent
def billing_profile_admin_status
return :complete if billing_profile_complete?
return :absent unless billing_profile_started?
:incomplete
end
def billing_profile_started?
billing_legal_name.present? ||
billing_vat_number.present? ||
billing_fiscal_code.present? ||
billing_email.present? ||
billing_phone.present? ||
billing_address_line.present? ||
billing_city.present? ||
billing_postal_code.present? ||
billing_province.present? ||
billing_recipient_code.present? ||
billing_pec.present?
end
private
def billing_profile_for_invoicing
+1 -1
View File
@@ -15,7 +15,7 @@ class StreamSession < ApplicationRecord
has_many :attempted_concurrency_violations, class_name: "StreamConcurrencyViolation",
foreign_key: :attempted_session_id, dependent: :nullify, inverse_of: :attempted_session
has_one :score_state, dependent: :destroy
has_one :recording
has_one :recording, dependent: :destroy
has_many :device_states, dependent: :destroy
validates :platform, inclusion: { in: PLATFORMS }
@@ -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
@@ -50,6 +50,14 @@
@filters[:device]
) %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.analytics.filters.chart_path") %></span>
<%= select_tag :chart_path,
options_for_select(
[[t("admin.analytics.filters.chart_path_all"), ""]] + @chart_path_options.map { |p| [p, p] },
@filters[:chart_path]
) %>
</label>
</div>
<div class="admin-filter-actions">
<%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
@@ -58,42 +66,66 @@
<% end %>
</div>
<% if @trend.any? && (@trend_totals[:pageviews].positive? || @trend_totals[:clicks].positive? || @trend_totals[:moves].positive?) %>
<section class="panel admin-analytics-trend">
<% if @trend.any? && @trend_totals[:pageviews].positive? %>
<section class="panel admin-analytics-trend" id="admin-analytics-trend">
<div class="admin-analytics-trend__head">
<div>
<h3><%= t("admin.analytics.index.trend_title") %></h3>
<p class="muted admin-table-sub"><%= t("admin.analytics.index.trend_lead") %></p>
<p class="muted admin-table-sub">
<% if @filters[:chart_path].present? %>
<%= t("admin.analytics.index.trend_lead_path", path: @filters[:chart_path]) %>
<% else %>
<%= t("admin.analytics.index.trend_lead") %>
<% end %>
</p>
</div>
<button type="button"
id="admin-analytics-trend-toggle"
class="admin-btn admin-btn--outline admin-btn--sm"
aria-controls="admin-analytics-trend-body"
aria-expanded="true"><%= t("admin.analytics.index.hide_chart") %></button>
</div>
<div id="admin-analytics-trend-body">
<div class="kpi-grid admin-analytics-trend__kpi">
<div class="kpi">
<div class="kpi-label"><%= t("admin.analytics.index.table.pageviews") %></div>
<div class="kpi-label">
<% if @filters[:chart_path].present? %>
<%= t("admin.analytics.index.trend_total_label_path") %>
<% else %>
<%= t("admin.analytics.index.trend_total_label") %>
<% end %>
</div>
<div class="kpi-value"><%= @trend_totals[:pageviews] %></div>
<div class="kpi-sub muted">
<% if @filters[:chart_path].present? %>
<code class="admin-mono"><%= @filters[:chart_path] %></code>
·
<% end %>
<%= t("admin.analytics.index.trend_total_hint", from: l(@filters[:from]), to: l(@filters[:to])) %>
</div>
<div class="kpi">
<div class="kpi-label"><%= t("admin.analytics.index.table.clicks") %></div>
<div class="kpi-value"><%= @trend_totals[:clicks] %></div>
</div>
<div class="kpi">
<div class="kpi-label"><%= t("admin.analytics.index.table.moves") %></div>
<div class="kpi-value"><%= @trend_totals[:moves] %></div>
</div>
</div>
<div class="chart-wrap chart-wrap--analytics">
<canvas id="chart-analytics-trend" aria-label="<%= t("admin.analytics.index.trend_title") %>"></canvas>
</div>
</div>
</section>
<script>
window.adminAnalyticsTrend = <%= raw @trend.to_json %>;
window.adminAnalyticsI18n = {
pageviews: <%= raw t("admin.analytics.index.table.pageviews").to_json %>,
clicks: <%= raw t("admin.analytics.index.table.clicks").to_json %>,
moves: <%= raw t("admin.analytics.index.table.moves").to_json %>
hideChart: <%= raw t("admin.analytics.index.hide_chart").to_json %>,
showChart: <%= raw t("admin.analytics.index.show_chart").to_json %>
};
</script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
<script src="/admin-analytics.js?v=1" defer></script>
<script src="/admin-analytics.js?v=3" defer></script>
<% end %>
<div class="panel">
<% if @pages.any? %>
<h3 class="admin-analytics-pages-title"><%= t("admin.analytics.index.pages_title") %></h3>
<p class="muted admin-table-sub"><%= t("admin.analytics.index.pages_lead") %></p>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
@@ -110,14 +142,18 @@
<tbody>
<% @pages.each do |row| %>
<% avg = row[:scroll_samples].positive? ? (row[:scroll_sum].to_f / row[:scroll_samples]).round : 0 %>
<tr>
<% chart_params = { from: @filters[:from], to: @filters[:to], chart_path: row[:page_path] } %>
<% chart_params[:device] = @filters[:device] if @filters[:device].present? %>
<tr class="<%= "is-chart-focus" if @filters[:chart_path] == row[:page_path] %>">
<td><code class="admin-mono"><%= row[:page_path] %></code></td>
<td><%= row[:pageviews] %></td>
<td><%= row[:moves] %></td>
<td><%= row[:clicks] %></td>
<td class="muted"><%= avg %>%</td>
<td class="muted"><%= row[:max_scroll] %>%</td>
<td>
<td class="admin-analytics-row-actions">
<%= link_to t("admin.analytics.index.chart_for_path"), admin_analytics_path(chart_params) %>
·
<% heatmap_params = { page_path: row[:page_path], from: @filters[:from], to: @filters[:to] } %>
<% heatmap_params[:device] = @filters[:device] if @filters[:device].present? %>
<%= link_to t("admin.analytics.index.heatmap"), admin_analytics_page_path(heatmap_params) %>
@@ -0,0 +1,46 @@
<% owner = club.owner %>
<% status = club.billing_profile_admin_status %>
<% lines = club.billing_profile_invoice_lines.select { |_label, value| value.present? } %>
<section class="panel admin-club-profile" style="margin:16px 0 24px">
<h3 style="font-size:1rem;margin:0 0 12px"><%= t("admin.clubs.show.profile_title") %></h3>
<h4 style="font-size:0.9rem;margin:0 0 8px;color:var(--muted)"><%= t("admin.clubs.show.owner_title") %></h4>
<% if owner %>
<dl class="billing-profile-dl" style="margin-bottom:16px">
<dt><%= t("admin.clubs.show.owner_name") %></dt>
<dd><%= owner.name %></dd>
<dt><%= t("admin.clubs.show.owner_email") %></dt>
<dd><%= mail_to owner.email %></dd>
</dl>
<% else %>
<p class="muted" style="margin:0 0 16px"><%= t("admin.clubs.show.owner_none") %></p>
<% end %>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px">
<h4 style="font-size:0.9rem;margin:0;color:var(--muted)"><%= t("admin.clubs.show.billing_title") %></h4>
<span class="admin-billing-status admin-billing-status--<%= status %>">
<%= t("admin.clubs.billing_status.#{status}") %>
</span>
<%= link_to t("admin.clubs.show.edit_link"), edit_admin_club_path(club), class: "admin-btn admin-btn--sm admin-btn--secondary" %>
</div>
<% if status == :absent || lines.empty? %>
<p class="muted" style="margin:0"><%= t("admin.clubs.show.billing_none") %></p>
<% else %>
<dl class="billing-profile-dl">
<% lines.each do |label, value| %>
<dt><%= label %></dt>
<dd><%= value %></dd>
<% end %>
</dl>
<% end %>
<% if status == :incomplete %>
<ul class="muted" style="margin:12px 0 0;padding-left:1.2rem;font-size:0.88rem">
<% club.billing_profile_errors.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
<% end %>
</section>
+139
View File
@@ -0,0 +1,139 @@
<p style="margin-bottom:16px">
<%= link_to t("admin.clubs.edit.back"), admin_club_path(@club) %>
</p>
<h2><%= t("admin.clubs.edit.title", club: @club.name) %></h2>
<p class="muted" style="margin-bottom:20px"><%= t("admin.clubs.edit.lead") %></p>
<% if flash.now[:alert].present? %>
<p class="admin-flash" style="background:#3d1b1b;border-color:#c62828"><%= flash.now[:alert] %></p>
<% end %>
<%= form_with url: admin_club_path(@club), method: :patch, local: true, class: "admin-form admin-form--wide" do %>
<section class="panel">
<h3><%= t("admin.clubs.edit.sections.club") %></h3>
<div class="admin-form-row">
<div>
<label for="club_name"><%= t("admin.clubs.edit.fields.name") %></label>
<input type="text" name="club[name]" id="club_name" value="<%= @club.name %>" required>
</div>
<div>
<label for="club_sport"><%= t("admin.clubs.edit.fields.sport") %></label>
<select name="club[sport]" id="club_sport" required>
<% @sport_options.each do |label, key| %>
<option value="<%= key %>" <%= "selected" if @club.sport == key %>><%= label %></option>
<% end %>
</select>
</div>
</div>
<div class="admin-form-row">
<div>
<label for="club_primary_color"><%= t("admin.clubs.edit.fields.primary_color") %></label>
<input type="text" name="club[primary_color]" id="club_primary_color" value="<%= @club.primary_color %>" maxlength="7">
</div>
<div>
<label for="club_secondary_color"><%= t("admin.clubs.edit.fields.secondary_color") %></label>
<input type="text" name="club[secondary_color]" id="club_secondary_color" value="<%= @club.secondary_color %>" maxlength="7">
</div>
</div>
<div>
<label for="club_logo_url"><%= t("admin.clubs.edit.fields.logo_url") %></label>
<input type="url" name="club[logo_url]" id="club_logo_url" value="<%= @club.logo_url %>">
</div>
</section>
<section class="panel">
<h3><%= t("admin.clubs.edit.sections.billing") %></h3>
<p class="muted admin-form-hint"><%= t("admin.clubs.edit.billing_hint") %></p>
<%= render "shared/billing_profile_fields", record: @club, show_legend: false %>
</section>
<section class="panel">
<h3><%= t("admin.clubs.edit.sections.owner") %></h3>
<% if @owner %>
<div class="admin-form-row">
<div>
<label for="owner_name"><%= t("admin.clubs.edit.fields.owner_name") %></label>
<input type="text" name="owner[name]" id="owner_name" value="<%= @owner.name %>" required>
</div>
<div>
<label for="owner_email"><%= t("admin.clubs.edit.fields.owner_email") %></label>
<input type="email" name="owner[email]" id="owner_email" value="<%= @owner.email %>" required>
</div>
</div>
<% else %>
<p class="muted"><%= t("admin.clubs.show.owner_none") %></p>
<% end %>
</section>
<section class="panel">
<h3><%= t("admin.clubs.edit.sections.staff") %></h3>
<% if @staff_users.any? %>
<p class="muted admin-form-hint"><%= t("admin.clubs.edit.staff_hint") %></p>
<% @staff_users.each do |user| %>
<div class="admin-form-row" style="margin-bottom:0.75rem">
<div>
<label><%= t("admin.clubs.edit.fields.staff_name") %></label>
<input type="text" name="staff_users[<%= user.id %>][name]" value="<%= user.name %>">
</div>
<div>
<label><%= t("admin.clubs.edit.fields.staff_email") %></label>
<input type="email" name="staff_users[<%= user.id %>][email]" value="<%= user.email %>">
</div>
</div>
<% end %>
<% else %>
<p class="muted"><%= t("admin.clubs.edit.staff_none") %></p>
<% end %>
</section>
<section class="panel">
<h3><%= t("admin.clubs.edit.sections.invitations") %></h3>
<% if @pending_invitations.any? %>
<p class="muted admin-form-hint"><%= t("admin.clubs.edit.invitations_hint") %></p>
<% @pending_invitations.each do |invitation| %>
<div class="admin-form-row" style="margin-bottom:0.75rem">
<div>
<label><%= t("admin.clubs.edit.fields.invitation_team") %></label>
<input type="text" value="<%= invitation.team.name %>" disabled>
</div>
<div>
<label><%= t("admin.clubs.edit.fields.invitation_email") %></label>
<input type="email" name="invitations[<%= invitation.id %>][email]" value="<%= invitation.email %>" required>
</div>
</div>
<% end %>
<% else %>
<p class="muted"><%= t("admin.clubs.edit.invitations_none") %></p>
<% end %>
</section>
<section class="panel">
<h3><%= t("admin.clubs.edit.sections.teams") %></h3>
<% if @teams.any? %>
<% @teams.each do |team| %>
<div class="admin-form-row" style="margin-bottom:0.75rem">
<div>
<label><%= t("admin.clubs.edit.fields.team_name") %></label>
<input type="text" name="teams[<%= team.id %>][name]" value="<%= team.name %>" required>
</div>
<div>
<label><%= t("admin.clubs.edit.fields.team_sport") %></label>
<select name="teams[<%= team.id %>][sport]">
<% @sport_options.each do |label, key| %>
<option value="<%= key %>" <%= "selected" if team.sport == key %>><%= label %></option>
<% end %>
</select>
</div>
</div>
<% end %>
<% else %>
<p class="muted"><%= t("admin.clubs.show.no_teams") %></p>
<% end %>
</section>
<div class="admin-form-actions">
<%= link_to t("admin.clubs.edit.cancel"), admin_club_path(@club), class: "admin-btn admin-btn--secondary" %>
<button type="submit" class="admin-btn admin-btn--primary"><%= t("admin.clubs.edit.submit") %></button>
</div>
<% end %>
@@ -9,6 +9,7 @@
<th><%= t("admin.clubs.index.table.club") %></th>
<th><%= t("admin.clubs.index.table.plan") %></th>
<th><%= t("admin.clubs.index.table.teams") %></th>
<th><%= t("admin.clubs.index.table.billing_profile") %></th>
<th><%= t("admin.clubs.index.table.comped") %></th>
<th><%= t("admin.clubs.index.table.stripe") %></th>
<th><%= t("admin.clubs.index.table.quote") %></th>
@@ -18,10 +19,16 @@
<tbody>
<% @clubs.each do |club| %>
<% sub = club.subscription %>
<% billing_status = club.billing_profile_admin_status %>
<tr>
<td><strong><%= club.name %></strong></td>
<td><%= sub&.plan&.name || t("admin.common.free_plan") %></td>
<td><%= club.teams.size %></td>
<td>
<span class="admin-billing-status admin-billing-status--<%= billing_status %>">
<%= t("admin.clubs.billing_status.#{billing_status}") %>
</span>
</td>
<td>
<% if sub&.admin_comped? %>
<span style="color:#ffb74d"><%= t("admin.common.yes") %></span>
@@ -6,8 +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) %>
</p>
<%= 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) %>
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<%= csrf_meta_tags %>
<link rel="stylesheet" href="/admin.css?v=17">
<link rel="stylesheet" href="/admin.css?v=21">
<%= yield :head %>
<% if content_for?(:replay_archive_styles) %>
<link rel="stylesheet" href="/marketing.css?v=42">
+63 -2
View File
@@ -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
@@ -239,8 +240,10 @@ de:
club: Verein
plan: Plan
teams: Teams
billing_profile: Rechnungsdaten
comped: Kostenlos
stripe: Stripe
quote: Vereinbart
manage: Verwalten
invoices: Rechnungen
show:
@@ -254,6 +257,13 @@ de:
youtube_premium_full_not_connected: Premium Full — Vereinskanal nicht verbunden (nutzt Match Live TV)
no_channel_hint_html: "Ohne verbundenen Kanal nutzt die App den Match-Live-TV-Kanal. %{link}."
link_channel: Kanal verbinden (Vereinsseite)
profile_title: Kontaktdaten & Rechnungsdaten
owner_title: Kontoinhaber
owner_name: Name
owner_email: E-Mail
owner_none: Kein Inhaber zugeordnet.
billing_title: Rechnungsdaten
billing_none: Keine Rechnungsdaten hinterlegt.
teams_title: Teams
no_teams: Keine Teams registriert.
table:
@@ -265,6 +275,46 @@ 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
absent: Fehlend
comped:
title: Kostenloses Abonnement
description: "Sponsor oder Aktion: Vergib Premium Light/Full ohne Stripe-Zahlung. Jederzeit widerrufbar."
@@ -415,6 +465,8 @@ de:
any: Alle
apply: Filtern
reset: Zurücksetzen
chart_path: Seite (Diagramm)
chart_path_all: Alle Seiten
layer: Ebene
layer_move: Mausbewegungen
layer_click: Klicks
@@ -427,8 +479,17 @@ de:
lead: Aggregierte First-Party-Heatmaps (Bewegung/Klick) und Scrolltiefe, nur mit Statistik-Einwilligung. Keine personenbezogenen Daten.
none: Keine Daten im gewählten Zeitraum.
heatmap: Heatmap
trend_title: Verlauf über die Zeit
trend_lead: Tägliche Pageviews, Klicks und Bewegungen im gefilterten Zeitraum.
chart_for_path: Diagramm
trend_title: Pageview-Verlauf
trend_lead: Gesamte Pageviews aller Seiten, Tag für Tag. Dieselben Filter Von / Bis / Gerät. Seite im Filter wählen oder «Diagramm» in der Tabelle.
trend_lead_path: "Tägliche Pageviews nur für %{path} (gleiche Filter Von / Bis / Gerät)."
trend_total_label: Pageviews gesamt (alle Seiten)
trend_total_label_path: Pageviews der gewählten Seite
trend_total_hint: "%{from} → %{to}"
hide_chart: Diagramm ausblenden
show_chart: Diagramm anzeigen
pages_title: Aufschlüsselung nach Seite
pages_lead: Pageviews und Engagement im gefilterten Zeitraum, nach Pfad. «Diagramm» zeigt den Verlauf einer Seite.
table:
path: Seite
pageviews: Pageviews
+63 -2
View File
@@ -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
@@ -239,8 +240,10 @@ en:
club: Club
plan: Plan
teams: Teams
billing_profile: Billing data
comped: Comp
stripe: Stripe
quote: Quoted
manage: Manage
invoices: Invoices
show:
@@ -254,6 +257,13 @@ en:
youtube_premium_full_not_connected: Premium Full — club channel not connected (using Match Live TV)
no_channel_hint_html: "Without a connected channel, the app uses the Match Live TV channel. %{link}."
link_channel: Connect channel (club page)
profile_title: Contact & billing details
owner_title: Account owner
owner_name: Name
owner_email: Email
owner_none: No owner associated.
billing_title: Billing details
billing_none: No billing details provided.
teams_title: Teams
no_teams: No teams registered.
table:
@@ -265,6 +275,46 @@ 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
absent: Missing
comped:
title: Complimentary subscription
description: "Sponsor or promotion: grant Premium Light/Full without a Stripe payment. Revocable at any time."
@@ -415,6 +465,8 @@ en:
any: All
apply: Filter
reset: Reset
chart_path: Page (chart)
chart_path_all: All pages
layer: Layer
layer_move: Mouse moves
layer_click: Clicks
@@ -427,8 +479,17 @@ en:
lead: Aggregated first-party move/click heatmaps and scroll depth, only with analytics consent. No personal data.
none: No data in the selected period.
heatmap: Heatmap
trend_title: Trend over time
trend_lead: Daily pageviews, clicks and moves for the filtered period.
chart_for_path: Chart
trend_title: Pageview trend
trend_lead: Total pageviews across all pages, day by day. Uses the same From / To / Device filters. Pick a page in the filter or click «Chart» in the table.
trend_lead_path: "Day-by-day pageviews for %{path} only (same From / To / Device filters)."
trend_total_label: Total pageviews (all pages)
trend_total_label_path: Pageviews for selected page
trend_total_hint: "%{from} → %{to}"
hide_chart: Hide chart
show_chart: Show chart
pages_title: Breakdown by page
pages_lead: Pageviews and engagement for the filtered period, split by path. Use «Chart» to see one page over time.
table:
path: Page
pageviews: Pageviews
+63 -2
View File
@@ -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
@@ -239,8 +240,10 @@ es:
club: Club
plan: Plan
teams: Equipos
billing_profile: Datos fiscales
comped: Cortesía
stripe: Stripe
quote: Acordado
manage: Gestionar
invoices: Facturas
show:
@@ -254,6 +257,13 @@ es:
youtube_premium_full_not_connected: Premium Full — canal del club no conectado (usa Match Live TV)
no_channel_hint_html: "Sin un canal conectado, la app usa el canal de Match Live TV. %{link}."
link_channel: Conectar canal (página del club)
profile_title: Contacto y datos fiscales
owner_title: Titular de la cuenta
owner_name: Nombre
owner_email: Correo
owner_none: No hay titular asociado.
billing_title: Datos de facturación
billing_none: No hay datos de facturación.
teams_title: Equipos
no_teams: No hay equipos registrados.
table:
@@ -265,6 +275,46 @@ 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
absent: Ausentes
comped:
title: Suscripción de cortesía
description: "Patrocinador o promoción: concede Premium Light/Full sin pago en Stripe. Revocable en cualquier momento."
@@ -415,6 +465,8 @@ es:
any: Todos
apply: Filtrar
reset: Restablecer
chart_path: Página (gráfico)
chart_path_all: Todas las páginas
layer: Capa
layer_move: Movimientos del ratón
layer_click: Clics
@@ -427,8 +479,17 @@ es:
lead: Heatmaps de movimientos/clics y scroll agregados (first-party), solo con consentimiento estadístico. Sin datos personales.
none: No hay datos en el periodo seleccionado.
heatmap: Heatmap
trend_title: Evolución en el tiempo
trend_lead: Pageviews, clics y movimientos día a día en el periodo filtrado.
chart_for_path: Gráfico
trend_title: Evolución de pageviews
trend_lead: Total de pageviews de todas las páginas, día a día. Usa los mismos filtros Desde / Hasta / Device. Elige una página en el filtro o pulsa «Gráfico» en la tabla.
trend_lead_path: "Pageviews día a día solo para %{path} (mismos filtros Desde / Hasta / Device)."
trend_total_label: Pageviews totales (todas las páginas)
trend_total_label_path: Pageviews de la página seleccionada
trend_total_hint: "%{from} → %{to}"
hide_chart: Ocultar gráfico
show_chart: Mostrar gráfico
pages_title: Detalle por página
pages_lead: Pageviews y engagement en el periodo filtrado, por path. Usa «Gráfico» para ver una sola página en el tiempo.
table:
path: Página
pageviews: Pageviews
+63 -2
View File
@@ -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
@@ -239,8 +240,10 @@ fr:
club: Club
plan: Forfait
teams: Équipes
billing_profile: Données fiscales
comped: Offert
stripe: Stripe
quote: Convenu
manage: Gérer
invoices: Factures
show:
@@ -254,6 +257,13 @@ fr:
youtube_premium_full_not_connected: Premium Full — chaîne du club non connectée (utilise Match Live TV)
no_channel_hint_html: "Sans chaîne connectée, l'application utilise la chaîne Match Live TV. %{link}."
link_channel: Connecter la chaîne (page du club)
profile_title: Coordonnées et données fiscales
owner_title: Titulaire du compte
owner_name: Nom
owner_email: E-mail
owner_none: Aucun titulaire associé.
billing_title: Données de facturation
billing_none: Aucune donnée de facturation renseignée.
teams_title: Équipes
no_teams: Aucune équipe enregistrée.
table:
@@ -265,6 +275,46 @@ fr:
concurrency_lead: Le même compte a tenté de démarrer un autre direct alors quun é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
absent: Absentes
comped:
title: Abonnement offert
description: "Sponsor ou promotion : accordez Premium Light/Full sans paiement Stripe. Révocable à tout moment."
@@ -415,6 +465,8 @@ fr:
any: Tous
apply: Filtrer
reset: Réinitialiser
chart_path: Page (graphique)
chart_path_all: Toutes les pages
layer: Couche
layer_move: Mouvements souris
layer_click: Clics
@@ -427,8 +479,17 @@ fr:
lead: Heatmaps mouvements/clics et scroll agrégés (first-party), uniquement avec consentement statistiques. Aucune donnée personnelle.
none: Aucune donnée sur la période sélectionnée.
heatmap: Heatmap
trend_title: Évolution dans le temps
trend_lead: Pages vues, clics et mouvements par jour sur la période filtrée.
chart_for_path: Graphique
trend_title: Évolution des pages vues
trend_lead: Total des pages vues sur toutes les pages, jour par jour. Mêmes filtres De / À / Appareil. Choisissez une page dans le filtre ou cliquez « Graphique » dans le tableau.
trend_lead_path: "Pages vues jour par jour pour %{path} uniquement (mêmes filtres De / À / Appareil)."
trend_total_label: Pages vues totales (toutes les pages)
trend_total_label_path: Pages vues de la page sélectionnée
trend_total_hint: "%{from} → %{to}"
hide_chart: Masquer le graphique
show_chart: Afficher le graphique
pages_title: Détail par page
pages_lead: Pages vues et engagement sur la période filtrée, par chemin. « Graphique » montre l’évolution dune page.
table:
path: Page
pageviews: Pages vues
+62 -2
View File
@@ -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
@@ -243,6 +244,7 @@ it:
club: Società
plan: Piano
teams: Squadre
billing_profile: Dati fiscali
comped: Omaggio
stripe: Stripe
quote: Concordato
@@ -259,6 +261,13 @@ it:
youtube_premium_full_not_connected: Premium Full — canale società non collegato (usa Match Live TV)
no_channel_hint_html: "Senza canale collegato, lapp usa il canale Match Live TV. %{link}."
link_channel: Collega canale (pagina società)
profile_title: Anagrafica e dati fiscali
owner_title: Titolare account
owner_name: Nome
owner_email: Email
owner_none: Nessun titolare associato.
billing_title: Dati di fatturazione
billing_none: Nessun dato di fatturazione compilato.
teams_title: Squadre
no_teams: Nessuna squadra registrata.
table:
@@ -270,6 +279,46 @@ it:
concurrency_lead: Stesso account che ha provato ad avviare unaltra 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
absent: Assenti
comped:
title: Abbonamento omaggio
description: "Sponsor o promozione: assegna Premium Light/Full senza pagamento Stripe. Revocabile in qualsiasi momento."
@@ -436,6 +485,8 @@ it:
any: Tutti
apply: Filtra
reset: Azzera
chart_path: Pagina (grafico)
chart_path_all: Tutte le pagine
layer: Livello
layer_move: Movimenti mouse
layer_click: Click
@@ -448,8 +499,17 @@ it:
lead: Heatmap movimenti/click e scroll aggregati (first-party), solo con consenso statistico. Nessun dato personale.
none: Nessun dato nel periodo selezionato.
heatmap: Heatmap
trend_title: Andamento nel tempo
trend_lead: Pageview, click e movimenti giorno per giorno nel periodo filtrato.
chart_for_path: Grafico
trend_title: Andamento pageview
trend_lead: Totale pageview di tutte le pagine, giorno per giorno. Usa gli stessi filtri Da / A / Device. Scegli una pagina nel filtro oppure clicca «Grafico» in tabella.
trend_lead_path: "Pageview giorno per giorno solo per %{path} (stessi filtri Da / A / Device)."
trend_total_label: Pageview totali (tutte le pagine)
trend_total_label_path: Pageview della pagina selezionata
trend_total_hint: "%{from} → %{to}"
hide_chart: Nascondi grafico
show_chart: Mostra grafico
pages_title: Dettaglio per pagina
pages_lead: Pageview e engagement nel periodo filtrato, spezzati per path. Usa «Grafico» per vedere landamento di una sola pagina.
table:
path: Pagina
pageviews: Pageview
+1 -1
View File
@@ -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
+35 -27
View File
@@ -1,7 +1,40 @@
(function () {
var STORAGE_KEY = "mltv_admin_analytics_chart_hidden";
var trend = window.adminAnalyticsTrend || [];
var i18n = window.adminAnalyticsI18n || {};
var panel = document.getElementById("admin-analytics-trend");
var canvas = document.getElementById("chart-analytics-trend");
var toggle = document.getElementById("admin-analytics-trend-toggle");
var body = document.getElementById("admin-analytics-trend-body");
function setHidden(hidden) {
if (!panel || !body || !toggle) return;
body.hidden = hidden;
panel.classList.toggle("is-collapsed", hidden);
toggle.setAttribute("aria-expanded", hidden ? "false" : "true");
toggle.textContent = hidden
? (i18n.showChart || "Mostra grafico")
: (i18n.hideChart || "Nascondi grafico");
try {
localStorage.setItem(STORAGE_KEY, hidden ? "1" : "0");
} catch (e) { /* ignore */ }
}
function initiallyHidden() {
try {
return localStorage.getItem(STORAGE_KEY) === "1";
} catch (e) {
return false;
}
}
if (toggle && body) {
setHidden(initiallyHidden());
toggle.addEventListener("click", function () {
setHidden(!body.hidden);
});
}
if (!canvas || !trend.length || typeof Chart === "undefined") return;
function dayLabel(dayStr) {
@@ -11,14 +44,10 @@
return d.toLocaleDateString([], { day: "2-digit", month: "short" });
}
var labels = trend.map(function (row) {
return dayLabel(row.day);
});
new Chart(canvas, {
type: "line",
data: {
labels: labels,
labels: trend.map(function (row) { return dayLabel(row.day); }),
datasets: [
{
label: i18n.pageviews || "Pageviews",
@@ -29,27 +58,6 @@
tension: 0.25,
pointRadius: 3,
pointHoverRadius: 5
},
{
label: i18n.clicks || "Clicks",
data: trend.map(function (r) { return r.clicks || 0; }),
borderColor: "rgba(66, 165, 245, 0.95)",
backgroundColor: "transparent",
fill: false,
tension: 0.25,
pointRadius: 2,
pointHoverRadius: 4
},
{
label: i18n.moves || "Moves",
data: trend.map(function (r) { return r.moves || 0; }),
borderColor: "rgba(255, 193, 7, 0.85)",
backgroundColor: "transparent",
fill: false,
tension: 0.25,
pointRadius: 2,
pointHoverRadius: 4,
borderDash: [4, 3]
}
]
},
@@ -74,7 +82,7 @@
}
},
plugins: {
legend: { labels: { color: "#ccc", boxWidth: 12 } }
legend: { display: false }
}
}
});
+71
View File
@@ -178,10 +178,39 @@ body.admin-body {
margin-top: 0.75rem;
}
.admin-analytics-trend__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.admin-analytics-trend__head h3 {
margin: 0 0 0.35rem;
}
.admin-analytics-trend__kpi {
margin: 0.75rem 0 0;
}
.admin-analytics-trend.is-collapsed .admin-analytics-trend__head {
margin-bottom: 0;
}
.admin-analytics-pages-title {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.admin-table tr.is-chart-focus td {
background: rgba(229, 57, 53, 0.12);
}
.admin-analytics-row-actions {
white-space: nowrap;
}
.admin-panels {
display: grid;
grid-template-columns: 1.4fr 1fr;
@@ -593,6 +622,35 @@ body.admin-body {
word-break: break-word;
}
.admin-billing-status {
display: inline-block;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
padding: 0.15rem 0.5rem;
border-radius: 999px;
border: 1px solid #3a3a45;
color: #ccc;
}
.admin-billing-status--complete {
border-color: #2e7d32;
color: #a5f0b8;
background: #1b3d1b;
}
.admin-billing-status--incomplete {
border-color: #f9a825;
color: #ffe082;
background: #3d3210;
}
.admin-billing-status--absent {
border-color: #555;
color: #999;
background: #1a1a22;
}
.billing-upload-form {
display: flex;
flex-direction: column;
@@ -623,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;
@@ -41,4 +41,15 @@ RSpec.describe ClubBillingProfile do
club.billing_fiscal_code = "RSSMRA80A01H501U"
expect(club.billing_profile_complete?).to be true
end
it "classifies admin billing status" do
expect(club.billing_profile_admin_status).to eq(:complete)
club.billing_recipient_code = nil
club.billing_pec = nil
expect(club.billing_profile_admin_status).to eq(:incomplete)
empty = Club.create!(name: "Empty", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff")
expect(empty.billing_profile_admin_status).to eq(:absent)
end
end
+39 -2
View File
@@ -7,6 +7,8 @@ RSpec.describe "Admin analytics", type: :request do
before do
post admin_login_path, params: { username: admin.username, password: "Password123" }
AnalyticsPageStat.delete_all
AnalyticsPageCell.delete_all
AnalyticsPageStat.create!(
day: Time.zone.today,
page_path: "/prezzi",
@@ -35,9 +37,12 @@ RSpec.describe "Admin analytics", type: :request do
expect(response.body).to include("40")
expect(response.body).to include("chart-analytics-trend")
expect(response.body).to include("adminAnalyticsTrend")
expect(response.body).to include(I18n.t("admin.analytics.index.hide_chart"))
expect(response.body).to include("hideChart")
expect(response.body).not_to include("clicks:")
end
it "mostra il trend anche su più giorni" do
it "il grafico rispetta i filtri data e aggrega pageview di tutte le pagine" do
AnalyticsPageStat.create!(
day: 1.day.ago.to_date,
page_path: "/prezzi",
@@ -47,13 +52,45 @@ RSpec.describe "Admin analytics", type: :request do
scroll_sum_pct: 0,
max_scroll_pct: 0
)
AnalyticsPageStat.create!(
day: Time.zone.today,
page_path: "/altro",
device: "desktop",
pageview_count: 7,
scroll_samples: 0,
scroll_sum_pct: 0,
max_scroll_pct: 0
)
get admin_analytics_path, params: { from: 2.days.ago.to_date, to: Time.zone.today }
expect(response).to have_http_status(:ok)
expect(response.body).to include(I18n.t("admin.analytics.index.trend_total_label"))
expect(response.body).to include(">24<")
expect(response.body).to include("\"pageviews\":5")
expect(response.body).to include("\"pageviews\":12")
expect(response.body).to include("\"pageviews\":19")
end
it "filtra il grafico su una sola pagina" do
AnalyticsPageStat.create!(
day: Time.zone.today,
page_path: "/altro",
device: "desktop",
pageview_count: 7,
scroll_samples: 0,
scroll_sum_pct: 0,
max_scroll_pct: 0
)
get admin_analytics_path, params: { chart_path: "/prezzi" }
expect(response).to have_http_status(:ok)
expect(response.body).to include(I18n.t("admin.analytics.index.trend_total_label_path"))
expect(response.body).to include(">12<")
expect(response.body).to include(I18n.t("admin.analytics.index.chart_for_path"))
expect(response.body).to include("is-chart-focus")
expect(response.body).not_to include(">19<")
end
it "mostra la heatmap di una pagina con tab dispositivo" do
get admin_analytics_page_path, params: { page_path: "/prezzi" }
expect(response).to have_http_status(:ok)
@@ -0,0 +1,95 @@
require "rails_helper"
RSpec.describe "Admin clubs billing profile", type: :request do
let!(:admin) { AdminAccount.create!(username: "ops-clubs-billing", password: "Password123") }
let!(:complete_club) do
Club.create!(
name: "Club Completo",
sport: "volleyball",
primary_color: "#e53935",
secondary_color: "#ffffff",
billing_entity_type: "company",
billing_legal_name: "ASD Completo",
billing_email: "fatture@completo.it",
billing_address_line: "Via Roma 1",
billing_city: "Milano",
billing_province: "MI",
billing_postal_code: "20100",
billing_country: "IT",
billing_vat_number: "12345678901",
billing_recipient_code: "ABCDEFG"
)
end
let!(:incomplete_club) do
Club.create!(
name: "Club Incompleto",
sport: "volleyball",
primary_color: "#e53935",
secondary_color: "#ffffff",
billing_entity_type: "company",
billing_legal_name: "ASD Incompleto",
billing_vat_number: "10987654321"
)
end
let!(:absent_club) do
Club.create!(
name: "Club Assente",
sport: "volleyball",
primary_color: "#e53935",
secondary_color: "#ffffff"
)
end
let!(:owner) do
user = User.create!(email: "owner-completo@test.it", name: "Mario Rossi", password: "Password123", role: "coach")
ClubMembership.create!(user: user, club: complete_club, role: "owner")
user
end
before do
post admin_login_path, params: { username: "ops-clubs-billing", password: "Password123" }
end
it "mostra i dati fiscali e il titolare nella scheda società" do
get admin_club_path(complete_club)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Anagrafica e dati fiscali")
expect(response.body).to include("Mario Rossi")
expect(response.body).to include("owner-completo@test.it")
expect(response.body).to include("ASD Completo")
expect(response.body).to include("12345678901")
expect(response.body).to include("ABCDEFG")
expect(response.body).to include("Completo")
end
it "mostra lo stato incompleto con errori mancanti" do
get admin_club_path(incomplete_club)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Incompleto")
expect(response.body).to include("ASD Incompleto")
expect(response.body).to include("10987654321")
end
it "mostra lo stato assente quando non ci sono dati fiscali" do
get admin_club_path(absent_club)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Assenti")
expect(response.body).to include("Nessun dato di fatturazione compilato.")
end
it "elenca lo stato del profilo fiscale nella lista società" do
get admin_clubs_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("Dati fiscali")
expect(response.body).to include("Completo")
expect(response.body).to include("Incompleto")
expect(response.body).to include("Assenti")
end
end
@@ -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
@@ -0,0 +1,37 @@
require "rails_helper"
RSpec.describe "Public match destroy with recording", type: :request do
let!(:user) { User.create!(email: "match-del@test.it", name: "Del", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "Del Club", sport: "pallavolo", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "U14", sport: "pallavolo") }
let!(:match) { team.matches.create!(opponent_name: "Rival", sport: "pallavolo") }
let!(:session) do
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended", ended_at: 1.hour.ago)
end
let!(:recording) do
Recording.create!(
stream_session: session,
team: team,
status: "ready",
storage_backend: "local",
storage_policy: "retained",
privacy_status: "unlisted"
)
end
before do
ClubMembership.create!(user: user, club: club, role: "owner")
UserTeam.create!(user: user, team: team, role: "member", staff_kind: "transmission")
post public_login_path, params: { email: user.email, password: "Password123" }
end
it "cancella la partita anche se esiste un recording collegato alla sessione" do
expect do
delete public_team_match_path(team, match)
end.to change(Match, :count).by(-1)
.and change(StreamSession, :count).by(-1)
.and change(Recording, :count).by(-1)
expect(response).to redirect_to(public_team_matches_path(team))
end
end
@@ -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