Allinea i numeri della dashboard tra griglia, funnel e liste di attenzione.

Il funnel mostra stage attuale e cumulato, i KPI escludono Da contattare e le liste usano il totale reale invece del tetto a 20.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 23:23:20 +02:00
co-authored by Cursor
parent 936930e691
commit 8f73105dfe
7 changed files with 271 additions and 85 deletions
+6
View File
@@ -120,6 +120,12 @@ module ApplicationHelper
number_to_currency(value.to_f, unit: "", separator: ",", delimiter: ".", format: "%n %u")
end
def format_conversion_rate(value)
return "" if value.nil?
"#{value}%"
end
def format_dt(value)
return "" if value.blank?
+69 -35
View File
@@ -1,4 +1,7 @@
class Dashboard::Metrics
FUNNEL_STAGES = (Catalog::OPEN_PIPELINE_STAGES + %w[won]).freeze
ATTENTION_PREVIEW = 8
def initialize(scope: Opportunity.all, project: nil)
@scope = scope
@project = project
@@ -21,7 +24,7 @@ class Dashboard::Metrics
end
def conversion_rates
stages = Catalog::OPEN_PIPELINE_STAGES + %w[won]
stages = FUNNEL_STAGES
rates = {}
stages.each_cons(2) do |from, to|
from_count = cumulative_reached(from)
@@ -32,22 +35,29 @@ class Dashboard::Metrics
end
def funnel_steps
counts = {
"contacted" => reached_or_beyond("contacted"),
"replied" => reached_or_beyond("replied"),
"interested" => reached_or_beyond("interested"),
"first_use" => reached_or_beyond("first_use"),
"won" => @scope.won.count
previous_reached = nil
previous_label = nil
FUNNEL_STAGES.map do |stage|
current = stage_counts[stage].to_i
reached = reached_or_beyond(stage)
label = Catalog.label_for(Catalog::PIPELINE_STAGES, stage)
step = {
stage: stage,
label: label,
current: current,
reached: reached
}
steps = []
previous = nil
counts.each do |stage, count|
rate = previous ? percentage(count, previous) : nil
steps << { stage: stage, label: Catalog.label_for(Catalog::PIPELINE_STAGES, stage), count: count, rate: rate }
previous = count
if previous_reached&.positive?
step[:rate] = percentage(reached, previous_reached)
step[:conversion_label] = "#{reached} su #{previous_reached} hanno superato #{previous_label}"
end
previous_reached = reached
previous_label = label
step
end
steps
end
def avg_days_to_won
@@ -83,17 +93,15 @@ class Dashboard::Metrics
end
def opportunities_without_next_action
opportunities_missing_next_action.count
in_trattativa_without_next_action.count
end
def demo_to_won_conversion
demo = reached_or_beyond("demo_trial")
percentage(@scope.won.count, demo)
stage_to_won_conversion("demo_trial")
end
def first_use_to_won_conversion
first_use = reached_or_beyond("first_use")
percentage(@scope.won.count, first_use)
stage_to_won_conversion("first_use")
end
def arpa
@@ -104,14 +112,16 @@ class Dashboard::Metrics
end
def attention_items
overdue = tasks_scope.overdue.includes(:organization, :contact, :assigned_user).ordered
{
to_send_count: @scope.where(send_status: "to_send").count,
interested_without_followup: interested_without_followup,
stalled_opportunities: stalled_opportunities,
overdue_tasks: tasks_scope.overdue.includes(:organization, :contact, :assigned_user).ordered.limit(20),
organizations_without_contacts: organizations_scope.left_joins(:contacts).where(contacts: { id: nil }).limit(20),
opportunities_without_value: advanced_open_stage.where(estimated_value: [nil, 0]).includes(:organization).limit(20),
opportunities_without_next_action: opportunities_missing_next_action.merge(advanced_open_stage).limit(20)
interested_without_followup: preview_attention(interested_without_followup_scope.includes(:organization)),
stalled_opportunities: preview_attention(stalled_opportunities_scope),
overdue_tasks: preview_attention(overdue),
organizations_without_contacts: preview_attention(organizations_without_contacts_scope),
opportunities_without_value: preview_attention(opportunities_without_value_scope),
opportunities_without_next_action: preview_attention(in_trattativa_without_next_action.includes(:organization))
}
end
@@ -133,7 +143,7 @@ class Dashboard::Metrics
idx = Catalog::PIPELINE_ORDER.index(stage)
return 0 unless idx
stages = Catalog::PIPELINE_ORDER[idx..] - ["lost"]
stages = Catalog::PIPELINE_ORDER[idx..].without("lost")
@scope.where(pipeline_stage: stages).count
end
@@ -143,25 +153,49 @@ class Dashboard::Metrics
((part.to_f / whole) * 100).round
end
def interested_without_followup
base = @scope.where(pipeline_stage: "interested")
with_pending = Task.pending.where.not(opportunity_id: nil).select(:opportunity_id)
base.where.not(id: with_pending).includes(:organization).limit(20)
def stage_to_won_conversion(stage)
return nil if stage_counts[stage].to_i.zero? && @scope.won.count.zero?
reached = reached_or_beyond(stage)
return nil if reached.zero?
percentage(@scope.won.count, reached)
end
def stalled_opportunities
def interested_without_followup_scope
@scope.where(pipeline_stage: "interested").where.not(id: pending_opportunity_ids)
end
def stalled_opportunities_scope
@scope.open_stage
.where("stage_changed_at < ? OR (stage_changed_at IS NULL AND opportunities.created_at < ?)", 7.days.ago, 7.days.ago)
.includes(:organization, :assigned_user)
.limit(20)
end
def opportunities_missing_next_action
with_pending = Task.pending.where.not(opportunity_id: nil).select(:opportunity_id)
@scope.open_stage.where.not(id: with_pending).includes(:organization)
def organizations_without_contacts_scope
organizations_scope.left_joins(:contacts).where(contacts: { id: nil })
end
def opportunities_without_value_scope
advanced_open_stage.where("estimated_value IS NULL OR estimated_value = 0").includes(:organization)
end
def in_trattativa_without_next_action
advanced_open_stage.where.not(id: pending_opportunity_ids)
end
def pending_opportunity_ids
Task.pending.where.not(opportunity_id: nil).select(:opportunity_id)
end
def advanced_open_stage
@scope.open_stage.where.not(pipeline_stage: "to_contact")
end
def preview_attention(relation)
total = relation.unscope(:includes, :preload, :eager_load, :order, :limit).count
total = total.size if total.is_a?(Hash)
{ items: relation.limit(ATTENTION_PREVIEW).to_a, total: total }
end
end
+5 -4
View File
@@ -1,10 +1,11 @@
<% items = items.to_a %>
<% total = local_assigns.fetch(:total) { items.size } %>
<% empty_class = "mt-2 text-sm text-amber-900/75 dark:text-amber-100/75" %>
<% if items.blank? %>
<% if total.to_i.zero? %>
<p class="<%= empty_class %>"><%= empty %></p>
<% else %>
<ul class="mt-2 space-y-1 text-sm">
<% items.first(8).each do |item| %>
<% items.each do |item| %>
<li>
<% if block_given? %>
<%= yield item %>
@@ -13,8 +14,8 @@
<% end %>
</li>
<% end %>
<% if items.size > 8 %>
<li class="text-xs text-amber-900/70 dark:text-amber-100/70">+ altre <%= items.size - 8 %></li>
<% if total > items.size %>
<li class="text-xs text-amber-900/70 dark:text-amber-100/70">+ altre <%= total - items.size %></li>
<% end %>
</ul>
<% end %>
+29 -21
View File
@@ -36,9 +36,12 @@
</div>
</section>
<section class="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
<section>
<p class="mb-3 text-sm text-slate-500">Conteggio per stage attuale: quante opportunità sono in quello stato ora.</p>
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
<% [
["Prospect totali", @metrics.prospect_count],
["Da contattare", @stage_counts["to_contact"]],
["Contattati", @stage_counts["contacted"]],
["Risposte", @stage_counts["replied"]],
["Interessati", @stage_counts["interested"]],
@@ -56,19 +59,24 @@
<div class="mt-1 text-2xl font-semibold tabular-nums"><%= value %></div>
</div>
<% end %>
</div>
</section>
<section class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-5">
<h2 class="text-lg font-semibold">Funnel commerciale</h2>
<p class="mt-1 text-sm text-slate-500">Numero grande: in questo stage ora. In piccolo: arrivati almeno a questo punto (stage attuale + successivi, esclusi i persi).</p>
<div class="mt-4 space-y-2">
<% @funnel.each_with_index do |step, idx| %>
<% if step[:rate] %>
<div class="text-center text-xs font-medium text-slate-500"><%= step[:rate] %>%</div>
<% @funnel.each do |step| %>
<% if step[:conversion_label] %>
<div class="text-center text-xs font-medium text-slate-500"><%= step[:conversion_label] %> (<%= step[:rate] %>%)</div>
<% end %>
<div class="flex items-center justify-between rounded-lg bg-zinc-50 px-4 py-3 dark:bg-zinc-800">
<span class="font-medium text-zinc-800 dark:text-zinc-100"><%= step[:label].upcase %></span>
<span class="text-xl font-semibold tabular-nums"><%= step[:count] %></span>
<span class="text-right">
<span class="text-xl font-semibold tabular-nums"><%= step[:current] %></span>
<span class="ml-2 text-xs font-medium text-slate-500"><%= step[:reached] %> da qui in poi</span>
</span>
</div>
<% end %>
</div>
@@ -79,10 +87,10 @@
<dl class="mt-4 space-y-3 text-sm break-anywhere">
<div class="flex justify-between"><dt class="text-slate-500">Giorni medi contatto → WON</dt><dd class="font-semibold"><%= @metrics.avg_days_to_won || "—" %></dd></div>
<div class="flex justify-between"><dt class="text-slate-500">Giorni medi dall'ultima attività</dt><dd class="font-semibold"><%= @metrics.avg_days_since_last_activity || "—" %></dd></div>
<div class="flex justify-between"><dt class="text-slate-500">Prospect senza attività > 7gg</dt><dd class="font-semibold"><%= @metrics.prospects_without_activity_over_7_days %></dd></div>
<div class="flex justify-between"><dt class="text-slate-500">Opp. senza next action</dt><dd class="font-semibold"><%= @metrics.opportunities_without_next_action %></dd></div>
<div class="flex justify-between"><dt class="text-slate-500">Demo/Trial → WON</dt><dd class="font-semibold"><%= @metrics.demo_to_won_conversion %>%</dd></div>
<div class="flex justify-between"><dt class="text-slate-500">First Use → WON</dt><dd class="font-semibold"><%= @metrics.first_use_to_won_conversion %>%</dd></div>
<div class="flex justify-between"><dt class="text-slate-500">Senza attività in timeline da &gt; 7 giorni</dt><dd class="font-semibold"><%= @metrics.prospects_without_activity_over_7_days %></dd></div>
<div class="flex justify-between"><dt class="text-slate-500">In trattativa senza next action</dt><dd class="font-semibold"><%= @metrics.opportunities_without_next_action %></dd></div>
<div class="flex justify-between"><dt class="text-slate-500">Demo/Trial → WON</dt><dd class="font-semibold"><%= format_conversion_rate(@metrics.demo_to_won_conversion) %></dd></div>
<div class="flex justify-between"><dt class="text-slate-500">First Use → WON</dt><dd class="font-semibold"><%= format_conversion_rate(@metrics.first_use_to_won_conversion) %></dd></div>
<div class="flex justify-between"><dt class="text-slate-500">ARPA (WON)</dt><dd class="font-semibold"><%= format_money(@metrics.arpa) %></dd></div>
<div class="flex justify-between"><dt class="text-slate-500">Pipeline value</dt><dd class="font-semibold"><%= format_money(@metrics.open_pipeline_value) %></dd></div>
</dl>
@@ -111,40 +119,40 @@
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div>
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Interessati senza follow-up</h3>
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Interessati senza follow-up (<%= @attention[:interested_without_followup][:total] %>)</h3>
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Hanno detto sì in linea: manca la prossima chiamata/email in agenda.</p>
<%= render "dashboard/attention_list", items: @attention[:interested_without_followup], empty: "Nessuno: nessun interessato scoperto." %>
<%= render "dashboard/attention_list", items: @attention[:interested_without_followup][:items], total: @attention[:interested_without_followup][:total], empty: "Nessuno: nessun interessato scoperto." %>
</div>
<div>
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Opportunità ferme da oltre 7 giorni</h3>
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Opportunità ferme da oltre 7 giorni (<%= @attention[:stalled_opportunities][:total] %>)</h3>
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Lo stage non si è mosso: va rilanciato o chiuso come perso.</p>
<%= render "dashboard/attention_list", items: @attention[:stalled_opportunities], empty: "Nessuna: nessuna trattativa ferma." do |opp| %>
<%= render "dashboard/attention_list", items: @attention[:stalled_opportunities][:items], total: @attention[:stalled_opportunities][:total], empty: "Nessuna: nessuna trattativa ferma." do |opp| %>
<%= link_to "#{opp.organization.name} (#{opp.days_in_current_stage}gg)", opp.organization, class: "hover:underline" %>
<% end %>
</div>
<div>
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Task scaduti</h3>
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Task scaduti (<%= @attention[:overdue_tasks][:total] %>)</h3>
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Promemoria già passati: completarli o ripianificarli.</p>
<%= render "dashboard/attention_list", items: @attention[:overdue_tasks], empty: "Nessuno: nessun task in ritardo." do |task| %>
<%= render "dashboard/attention_list", items: @attention[:overdue_tasks][:items], total: @attention[:overdue_tasks][:total], empty: "Nessuno: nessun task in ritardo." do |task| %>
<%= link_to "#{task.organization.name}: #{task.title}", task.organization, class: "hover:underline" %>
<% end %>
</div>
<div>
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Senza contatto</h3>
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Senza contatto (<%= @attention[:organizations_without_contacts][:total] %>)</h3>
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Manca una persona a cui scrivere o telefonare.</p>
<%= render "dashboard/attention_list", items: @attention[:organizations_without_contacts], empty: "Nessuna: tutte hanno un contatto." do |org| %>
<%= render "dashboard/attention_list", items: @attention[:organizations_without_contacts][:items], total: @attention[:organizations_without_contacts][:total], empty: "Nessuna: tutte hanno un contatto." do |org| %>
<%= link_to org.name, org, class: "hover:underline" %>
<% end %>
</div>
<div>
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Senza valore (già in trattativa)</h3>
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Senza valore (già in trattativa) (<%= @attention[:opportunities_without_value][:total] %>)</h3>
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Dopo il primo contatto serve un importo stimato (Light/Full…) per la pipeline.</p>
<%= render "dashboard/attention_list", items: @attention[:opportunities_without_value], empty: "Nessuna in trattativa senza valore." %>
<%= render "dashboard/attention_list", items: @attention[:opportunities_without_value][:items], total: @attention[:opportunities_without_value][:total], empty: "Nessuna in trattativa senza valore." %>
</div>
<div>
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Senza prossima azione (già in trattativa)</h3>
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Senza prossima azione (già in trattativa) (<%= @attention[:opportunities_without_next_action][:total] %>)</h3>
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Dopo linvio o la risposta manca un task in agenda: la trattativa si ferma.</p>
<%= render "dashboard/attention_list", items: @attention[:opportunities_without_next_action], empty: "Nessuna in trattativa senza next action." %>
<%= render "dashboard/attention_list", items: @attention[:opportunities_without_next_action][:items], total: @attention[:opportunities_without_next_action][:total], empty: "Nessuna in trattativa senza next action." %>
</div>
</div>
</section>
+18 -5
View File
@@ -8,22 +8,35 @@
<thead>
<tr class="text-left text-xs uppercase tracking-wide text-zinc-500">
<th class="py-2">Stage</th>
<th>Count</th>
<th>In questo stage</th>
<th>Da qui in poi</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
<% @funnel[:stages].each do |s| %>
<% @funnel[:funnel].each do |step| %>
<tr>
<td class="py-2"><%= s[:label] %></td>
<td class="font-semibold"><%= s[:count] %></td>
<td class="py-2"><%= step[:label] %></td>
<td class="font-semibold"><%= step[:current] %></td>
<td><%= step[:reached] %></td>
</tr>
<% end %>
<% lost = @funnel[:stages].find { |s| s[:stage] == "lost" } %>
<% if lost %>
<tr>
<td class="py-2"><%= lost[:label] %></td>
<td class="font-semibold"><%= lost[:count] %></td>
<td></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<p class="mt-3 text-xs text-zinc-500">In questo stage = foto attuale. Da qui in poi = stage attuale + successivi, esclusi i persi.</p>
<div class="mt-4 space-y-1 text-sm text-zinc-600 dark:text-zinc-300">
<% @funnel[:funnel].each do |step| %>
<div><%= step[:label] %>: <strong class="text-zinc-900 dark:text-zinc-50"><%= step[:count] %></strong><% if step[:rate] %> (<%= step[:rate] %>% dal passo precedente)<% end %></div>
<% if step[:conversion_label] %>
<div><%= step[:conversion_label] %> <strong class="text-zinc-900 dark:text-zinc-50">(<%= step[:rate] %>%)</strong></div>
<% end %>
<% end %>
</div>
</section>
@@ -0,0 +1,26 @@
require "test_helper"
class DashboardControllerTest < ActionDispatch::IntegrationTest
test "dashboard shows snapshot cards and dual funnel counts" do
login_as users(:admin)
get project_root_path(project_code: "matchlivetv")
assert_response :success
assert_match(/Da contattare/, response.body)
assert_match(/Conteggio per stage attuale/, response.body)
assert_match(/da qui in poi/, response.body)
assert_match(/In trattativa senza next action/, response.body)
assert_match(/Senza attività in timeline/, response.body)
assert_no_match(/\d+%/, response.body)
end
test "reports funnel uses current and reached columns" do
login_as users(:admin)
get reports_path(project_code: "matchlivetv")
assert_response :success
assert_match(/In questo stage/, response.body)
assert_match(/Da qui in poi/, response.body)
assert_no_match(/dal passo precedente/, response.body)
end
end
+98
View File
@@ -1,16 +1,114 @@
require "test_helper"
class DashboardMetricsTest < ActiveSupport::TestCase
setup do
@project = projects(:matchlivetv)
Current.user = users(:marco)
Current.project = @project
@opps = []
end
teardown do
Current.user = nil
Current.project = nil
end
test "computes stage counts and pipeline value" do
metrics = Dashboard::Metrics.new
assert metrics.stage_counts.key?("interested")
assert metrics.stage_counts.key?("to_contact")
assert_kind_of Numeric, metrics.open_pipeline_value
assert_kind_of Array, metrics.funnel_steps
end
test "funnel shows current snapshot and reached-or-beyond without skipping stages" do
add_opp("to_contact")
2.times { add_opp("contacted") }
add_opp("replied")
add_opp("interested")
2.times { add_opp("proposal", estimated_value: 100) }
add_opp("lost", lost_reason: "price")
steps = metrics.funnel_steps.index_by { |step| step[:stage] }
assert_equal Dashboard::Metrics::FUNNEL_STAGES, metrics.funnel_steps.map { |step| step[:stage] }
assert_equal 2, steps["contacted"][:current]
assert_equal 6, steps["contacted"][:reached]
assert_equal 0, steps["first_use"][:current]
assert_equal 2, steps["first_use"][:reached]
assert_equal 2, steps["proposal"][:current]
assert_equal 2, steps["proposal"][:reached]
assert_equal 0, steps["won"][:current]
refute steps.key?("lost")
contacted_step = steps["replied"]
assert_equal "4 su 6 hanno superato Contattato", contacted_step[:conversion_label]
assert_equal 67, contacted_step[:rate]
end
test "next action KPI excludes da contattare and attention remainder uses real total" do
2.times { add_opp("to_contact", send_status: "to_send") }
10.times { add_opp("contacted") }
interested = add_opp("interested")
Task.create!(
title: "Richiama",
organization: interested.organization,
opportunity: interested,
assigned_user: users(:marco),
due_at: 1.day.from_now,
priority: "normal",
task_type: "call",
status: "pending"
)
assert_equal 10, metrics.opportunities_without_next_action
preview = metrics.attention_items[:opportunities_without_next_action]
assert_equal 10, preview[:total]
assert_equal 8, preview[:items].size
end
test "demo and first use conversions are blank when those stages were never used" do
add_opp("proposal")
assert_nil metrics.demo_to_won_conversion
assert_nil metrics.first_use_to_won_conversion
end
test "demo conversion is zero when someone is in demo and nobody won" do
add_opp("demo_trial")
assert_equal 0, metrics.demo_to_won_conversion
end
test "goal progress" do
goal = sales_goals(:september)
assert_equal 0, goal.current_value
assert_equal 0, goal.progress_percentage
end
private
def metrics
Dashboard::Metrics.new(scope: Opportunity.where(id: @opps.map(&:id)), project: @project)
end
def add_opp(stage, **attrs)
org = Organization.create!(
name: "Soc #{stage} #{SecureRandom.hex(3)}",
status: "prospect",
organization_type: "societa_sportiva"
)
opp = Opportunity.create!(
{
organization: org,
project: @project,
name: "Deal #{stage}",
pipeline_stage: stage
}.merge(attrs)
)
@opps << opp
opp
end
end