Admin analytics: heatmap per device, anteprima staff e analisi costi.
Separa heatmap mobile/desktop, opt-out analytics per operatori, dashboard costi con KPI e trend mensili. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,32 @@
|
||||
<p class="muted admin-page-sub"><%= t("admin.analytics.index.lead") %></p>
|
||||
</div>
|
||||
|
||||
<section class="panel admin-analytics-preview">
|
||||
<h3><%= t("admin.analytics.preview.title") %></h3>
|
||||
<p class="muted admin-table-sub"><%= t("admin.analytics.preview.lead") %></p>
|
||||
<% if @analytics_preview_active %>
|
||||
<p class="admin-analytics-preview__status"><%= t("admin.analytics.preview.active") %></p>
|
||||
<div class="admin-filter-actions">
|
||||
<%= button_to t("admin.analytics.preview.disable"),
|
||||
admin_analytics_preview_path,
|
||||
method: :delete,
|
||||
class: "admin-btn admin-btn--outline admin-btn--sm" %>
|
||||
<%= link_to t("admin.analytics.preview.open_site"),
|
||||
root_path,
|
||||
class: "admin-btn admin-btn--primary admin-btn--sm",
|
||||
target: "_blank",
|
||||
rel: "noopener" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="admin-filter-actions">
|
||||
<%= button_to t("admin.analytics.preview.enable"),
|
||||
admin_analytics_preview_path(return_to: root_path),
|
||||
method: :post,
|
||||
class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<div class="panel admin-sessions-filters">
|
||||
<%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||
<div class="admin-filter-grid">
|
||||
@@ -58,8 +84,9 @@
|
||||
<td class="muted"><%= avg %>%</td>
|
||||
<td class="muted"><%= row[:max_scroll] %>%</td>
|
||||
<td>
|
||||
<%= link_to t("admin.analytics.index.heatmap"),
|
||||
admin_analytics_page_path(page_path: row[:page_path], from: @filters[:from], to: @filters[:to], device: @filters[:device]) %>
|
||||
<% 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) %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="admin-page-head admin-session-head">
|
||||
<div>
|
||||
<p class="muted admin-page-sub">
|
||||
<%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to], device: @filters[:device]) %>
|
||||
<%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to]) %>
|
||||
</p>
|
||||
<h2 class="admin-page-title"><%= t("admin.analytics.show.title") %></h2>
|
||||
<p><code class="admin-mono"><%= @page_path %></code></p>
|
||||
@@ -11,8 +11,32 @@
|
||||
</div>
|
||||
|
||||
<div class="panel admin-sessions-filters">
|
||||
<p class="admin-filter-field" style="margin-bottom: 0.75rem;">
|
||||
<span><%= t("admin.analytics.show.device_tabs_label") %></span>
|
||||
</p>
|
||||
<div class="admin-locale-tabs" role="tablist">
|
||||
<% AnalyticsEvent::DEVICES.each do |device| %>
|
||||
<% stats = @device_tab_stats[device] %>
|
||||
<% active = @filters[:device] == device %>
|
||||
<%= link_to admin_analytics_page_path(
|
||||
page_path: @page_path,
|
||||
from: @filters[:from],
|
||||
to: @filters[:to],
|
||||
device: device,
|
||||
layer: @filters[:layer]
|
||||
),
|
||||
class: "admin-locale-tab #{'is-active' if active}",
|
||||
role: "tab",
|
||||
"aria-selected": active do %>
|
||||
<%= t("admin.analytics.devices.#{device}") %>
|
||||
<span class="admin-device-tab-count"><%= stats[:pageviews] %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||
<%= hidden_field_tag :page_path, @page_path %>
|
||||
<%= hidden_field_tag :device, @filters[:device] %>
|
||||
<div class="admin-filter-grid">
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.analytics.filters.from") %></span>
|
||||
@@ -22,14 +46,6 @@
|
||||
<span><%= t("admin.analytics.filters.to") %></span>
|
||||
<%= date_field_tag :to, @filters[:to] %>
|
||||
</label>
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.analytics.filters.device") %></span>
|
||||
<%= select_tag :device,
|
||||
options_for_select(
|
||||
[[t("admin.analytics.filters.any"), ""]] + AnalyticsEvent::DEVICES.map { |d| [d, d] },
|
||||
@filters[:device]
|
||||
) %>
|
||||
</label>
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.analytics.filters.layer") %></span>
|
||||
<%= select_tag :layer,
|
||||
@@ -91,7 +107,12 @@
|
||||
<h3>
|
||||
<%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %>
|
||||
</h3>
|
||||
<p class="muted admin-table-sub"><%= t("admin.analytics.show.heatmap_hint") %></p>
|
||||
<p class="muted admin-table-sub">
|
||||
<%= t(
|
||||
"admin.analytics.show.heatmap_hint",
|
||||
device: t("admin.analytics.devices.#{@filters[:device]}")
|
||||
) %>
|
||||
</p>
|
||||
|
||||
<% if @cells.any? %>
|
||||
<div class="admin-heatmap-stage" id="admin-heatmap-stage">
|
||||
@@ -115,13 +136,20 @@
|
||||
></canvas>
|
||||
</div>
|
||||
<p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;">
|
||||
<%= t("admin.analytics.show.snapshot_meta",
|
||||
device: @snapshot.device,
|
||||
at: l(@snapshot.captured_at, format: :short)) %>
|
||||
<%= t(
|
||||
"admin.analytics.show.snapshot_meta",
|
||||
device: t("admin.analytics.devices.#{@snapshot.device}"),
|
||||
at: l(@snapshot.captured_at, format: :short)
|
||||
) %>
|
||||
</p>
|
||||
<% else %>
|
||||
<div class="admin-heatmap-fallback" aria-hidden="true"></div>
|
||||
<p class="muted admin-heatmap-fallback-note"><%= t("admin.analytics.show.snapshot_missing") %></p>
|
||||
<p class="muted admin-heatmap-fallback-note">
|
||||
<%= t(
|
||||
"admin.analytics.show.snapshot_missing",
|
||||
device: t("admin.analytics.devices.#{@filters[:device]}")
|
||||
) %>
|
||||
</p>
|
||||
<canvas
|
||||
id="admin-heatmap"
|
||||
class="admin-heatmap-overlay"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<%= form_with model: @entry, url: url, method: method, local: true, class: "admin-filter-form" do |f| %>
|
||||
<div class="admin-filter-grid">
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.costs.entries.month") %></span>
|
||||
<%= text_field_tag "platform_cost_entry[month]",
|
||||
@entry.month&.strftime("%Y-%m"),
|
||||
type: "month",
|
||||
required: true %>
|
||||
</label>
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.costs.entries.label") %></span>
|
||||
<%= f.text_field :label, required: true, maxlength: 120 %>
|
||||
<span class="muted admin-table-sub"><%= t("admin.costs.entries.label_hint") %></span>
|
||||
</label>
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.costs.entries.amount") %></span>
|
||||
<%= number_field_tag "platform_cost_entry[amount_euros]",
|
||||
(@entry.amount_cents.to_f / 100.0 if @entry.amount_cents.present?),
|
||||
step: 0.01,
|
||||
min: 0.01,
|
||||
required: true %>
|
||||
</label>
|
||||
<label class="admin-filter-field" style="grid-column: span 2;">
|
||||
<span><%= t("admin.costs.entries.notes") %></span>
|
||||
<%= f.text_area :notes, rows: 3 %>
|
||||
</label>
|
||||
</div>
|
||||
<div class="admin-filter-actions">
|
||||
<%= f.submit t("admin.costs.entries.save"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||
<%= link_to t("admin.costs.entries.cancel"),
|
||||
admin_costs_path(month: @entry.month&.strftime("%Y-%m")),
|
||||
class: "admin-btn admin-btn--outline admin-btn--sm" %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -0,0 +1,10 @@
|
||||
<% content_for :body_class, "admin-body" %>
|
||||
|
||||
<div class="admin-page-head">
|
||||
<h2 class="admin-page-title"><%= t("admin.costs.entries.edit_title") %></h2>
|
||||
<p class="muted admin-page-sub"><%= @entry.label %> · <%= admin_month_label(@entry.month) %></p>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<%= render "form", url: admin_cost_entry_path(@entry), method: :patch %>
|
||||
</section>
|
||||
@@ -0,0 +1,9 @@
|
||||
<% content_for :body_class, "admin-body" %>
|
||||
|
||||
<div class="admin-page-head">
|
||||
<h2 class="admin-page-title"><%= t("admin.costs.entries.new_title") %></h2>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<%= render "form", url: admin_cost_entries_path, method: :post %>
|
||||
</section>
|
||||
@@ -0,0 +1,197 @@
|
||||
<% content_for :body_class, "admin-body" %>
|
||||
<% content_for :head do %>
|
||||
<script>
|
||||
window.adminCostTrend = <%= raw @trend.to_json %>;
|
||||
window.adminCostI18n = {
|
||||
cost: <%= raw t("admin.costs.charts.cost_revenue").to_json %>,
|
||||
revenue: <%= raw t("admin.costs.kpi.revenue").to_json %>,
|
||||
margin: <%= raw t("admin.costs.kpi.margin").to_json %>,
|
||||
costPerHour: <%= raw t("admin.costs.charts.cost_per_hour").to_json %>,
|
||||
hours: <%= raw t("admin.costs.charts.hours").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-costs.js?v=1" defer></script>
|
||||
<% end %>
|
||||
|
||||
<div class="admin-page-head">
|
||||
<h2 class="admin-page-title"><%= t("admin.costs.index.title") %></h2>
|
||||
<p class="muted admin-page-sub"><%= t("admin.costs.index.lead") %></p>
|
||||
</div>
|
||||
|
||||
<div class="panel admin-sessions-filters">
|
||||
<%= form_with url: admin_costs_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||
<div class="admin-filter-grid">
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.costs.index.month") %></span>
|
||||
<%= select_tag :month,
|
||||
options_for_select(
|
||||
@month_options.map { |m| [admin_month_label(m), m.strftime("%Y-%m")] },
|
||||
@month.strftime("%Y-%m")
|
||||
) %>
|
||||
</label>
|
||||
</div>
|
||||
<div class="admin-filter-actions">
|
||||
<%= submit_tag t("admin.costs.index.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||
<%= link_to t("admin.costs.index.add_entry"),
|
||||
new_admin_cost_entry_path(month: @month.strftime("%Y-%m")),
|
||||
class: "admin-btn admin-btn--outline admin-btn--sm" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<section class="kpi-grid">
|
||||
<div class="kpi-card kpi-card--accent">
|
||||
<div class="kpi-label"><%= t("admin.costs.kpi.platform_cost") %></div>
|
||||
<div class="kpi-value"><%= format_euros(@summary[:cost_cents]) %></div>
|
||||
<div class="kpi-sub"><%= admin_month_label(@month) %></div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label"><%= t("admin.costs.kpi.revenue") %></div>
|
||||
<div class="kpi-value"><%= format_euros(@summary[:revenue_cents]) %></div>
|
||||
<div class="kpi-sub">
|
||||
<%= t("admin.costs.kpi.margin") %>: <%= format_euros(@summary[:margin_cents]) %>
|
||||
<% if @summary[:margin_pct] %>
|
||||
· <%= @summary[:margin_pct] %>%
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label"><%= t("admin.costs.kpi.cost_per_hour") %></div>
|
||||
<div class="kpi-value"><%= format_euros(@summary[:cost_per_hour_cents]) %></div>
|
||||
<div class="kpi-sub">
|
||||
<%= t("admin.costs.kpi.revenue_per_hour") %>: <%= format_euros(@summary[:revenue_per_hour_cents]) %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label"><%= t("admin.costs.kpi.hours") %></div>
|
||||
<div class="kpi-value"><%= format_hours(@summary[:hours]) %></div>
|
||||
<div class="kpi-sub">
|
||||
<%= t("admin.costs.kpi.sessions") %>: <%= @summary[:sessions] %>
|
||||
· <%= t("admin.costs.kpi.clubs_active") %>: <%= @summary[:clubs_active] %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label"><%= t("admin.costs.kpi.cost_per_session") %></div>
|
||||
<div class="kpi-value"><%= format_euros(@summary[:cost_per_session_cents]) %></div>
|
||||
<div class="kpi-sub"><%= t("admin.costs.kpi.cost_per_club") %>: <%= format_euros(@summary[:cost_per_club_cents]) %></div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label"><%= t("admin.costs.kpi.storage") %></div>
|
||||
<div class="kpi-value" style="font-size:1.2rem"><%= format_bytes(@summary[:storage_bytes]) %></div>
|
||||
<div class="kpi-sub">
|
||||
<%= t("admin.costs.kpi.cost_per_gb") %>: <%= format_euros(@summary[:cost_per_gb_cents]) %>
|
||||
· <%= t("admin.costs.kpi.storage_note") %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" style="margin-bottom:1.25rem">
|
||||
<h3><%= t("admin.costs.index.trends_title") %></h3>
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<h3><%= t("admin.costs.charts.cost_revenue") %></h3>
|
||||
<div class="chart-wrap"><canvas id="chart-cost-revenue"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3><%= t("admin.costs.charts.cost_per_hour") %></h3>
|
||||
<div class="chart-wrap"><canvas id="chart-cost-hour"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3><%= t("admin.costs.charts.hours") %></h3>
|
||||
<div class="chart-wrap"><canvas id="chart-hours"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" style="margin-bottom:1.25rem">
|
||||
<h3><%= t("admin.costs.index.entries_title") %></h3>
|
||||
<p class="muted admin-table-sub"><%= t("admin.costs.index.entries_lead") %></p>
|
||||
<% if @entries.any? %>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("admin.costs.table.label") %></th>
|
||||
<th><%= t("admin.costs.table.amount") %></th>
|
||||
<th><%= t("admin.costs.table.notes") %></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @entries.each do |entry| %>
|
||||
<tr>
|
||||
<td><strong><%= entry.label %></strong></td>
|
||||
<td><%= format_euros(entry.amount_cents) %></td>
|
||||
<td class="muted"><%= entry.notes.presence || t("admin.common.dash") %></td>
|
||||
<td class="admin-actions">
|
||||
<%= link_to t("admin.announcements.actions.edit"),
|
||||
edit_admin_cost_entry_path(entry),
|
||||
class: "admin-btn admin-btn--sm" %>
|
||||
<%= button_to t("admin.costs.entries.delete"),
|
||||
admin_cost_entry_path(entry),
|
||||
method: :delete,
|
||||
class: "admin-btn admin-btn--sm admin-btn--danger",
|
||||
form: { data: { confirm: t("admin.costs.entries.delete_confirm") } } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th><%= t("admin.costs.kpi.platform_cost") %></th>
|
||||
<th><%= format_euros(@summary[:cost_cents]) %></th>
|
||||
<th colspan="2"></th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="empty"><%= t("admin.costs.index.no_entries") %></p>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h3><%= t("admin.costs.index.clubs_title") %></h3>
|
||||
<p class="muted admin-table-sub"><%= t("admin.costs.index.clubs_lead") %></p>
|
||||
<% if @clubs.any? %>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("admin.costs.table.club") %></th>
|
||||
<th><%= t("admin.costs.table.plan") %></th>
|
||||
<th><%= t("admin.costs.table.hours") %></th>
|
||||
<th><%= t("admin.costs.table.hours_share") %></th>
|
||||
<th><%= t("admin.costs.table.allocated_cost") %></th>
|
||||
<th><%= t("admin.costs.table.revenue") %></th>
|
||||
<th><%= t("admin.costs.table.margin") %></th>
|
||||
<th><%= t("admin.costs.table.cost_per_hour") %></th>
|
||||
<th><%= t("admin.costs.table.cost_per_session") %></th>
|
||||
<th><%= t("admin.costs.table.storage") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @clubs.each do |row| %>
|
||||
<tr>
|
||||
<td>
|
||||
<%= link_to row[:club_name], admin_club_path(row[:club_id]), class: "admin-table-strong" %>
|
||||
</td>
|
||||
<td class="muted"><%= row[:plan_slug] || t("admin.common.dash") %></td>
|
||||
<td><%= format_hours(row[:hours]) %></td>
|
||||
<td class="muted"><%= row[:hours_share_pct] %>%</td>
|
||||
<td><%= format_euros(row[:allocated_cost_cents]) %></td>
|
||||
<td><%= format_euros(row[:revenue_cents]) %></td>
|
||||
<td><%= format_euros(row[:margin_cents]) %></td>
|
||||
<td><%= format_euros(row[:cost_per_hour_cents]) %></td>
|
||||
<td><%= format_euros(row[:cost_per_session_cents]) %></td>
|
||||
<td class="muted"><%= format_bytes(row[:storage_bytes]) %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="empty"><%= t("admin.costs.index.no_clubs") %></p>
|
||||
<% end %>
|
||||
</section>
|
||||
Reference in New Issue
Block a user