diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index cbd0c82..556eedd 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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? diff --git a/app/services/dashboard/metrics.rb b/app/services/dashboard/metrics.rb index 3152769..b25a0c1 100644 --- a/app/services/dashboard/metrics.rb +++ b/app/services/dashboard/metrics.rb @@ -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 - 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 + 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 + } + + 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 diff --git a/app/views/dashboard/_attention_list.html.erb b/app/views/dashboard/_attention_list.html.erb index 9a82c29..9c4d6e3 100644 --- a/app/views/dashboard/_attention_list.html.erb +++ b/app/views/dashboard/_attention_list.html.erb @@ -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? %>

<%= empty %>

<% else %> <% end %> diff --git a/app/views/dashboard/show.html.erb b/app/views/dashboard/show.html.erb index 3492dca..6843a69 100644 --- a/app/views/dashboard/show.html.erb +++ b/app/views/dashboard/show.html.erb @@ -36,39 +36,47 @@ -
- <% [ - ["Prospect totali", @metrics.prospect_count], - ["Contattati", @stage_counts["contacted"]], - ["Risposte", @stage_counts["replied"]], - ["Interessati", @stage_counts["interested"]], - ["Demo/Trial", @stage_counts["demo_trial"]], - ["Primo utilizzo", @stage_counts["first_use"]], - ["Proposte", @stage_counts["proposal"]], - ["Clienti acquisiti", @stage_counts["won"]], - ["Persi", @stage_counts["lost"]], - ["Valore WON", format_money(@metrics.won_value)], - ["Pipeline aperta", format_money(@metrics.open_pipeline_value)], - ["Task scaduti", @metrics.overdue_tasks_count] - ].each do |label, value| %> -
-
<%= label %>
-
<%= value %>
-
- <% end %> +
+

Conteggio per stage attuale: quante opportunità sono in quello stato ora.

+
+ <% [ + ["Prospect totali", @metrics.prospect_count], + ["Da contattare", @stage_counts["to_contact"]], + ["Contattati", @stage_counts["contacted"]], + ["Risposte", @stage_counts["replied"]], + ["Interessati", @stage_counts["interested"]], + ["Demo/Trial", @stage_counts["demo_trial"]], + ["Primo utilizzo", @stage_counts["first_use"]], + ["Proposte", @stage_counts["proposal"]], + ["Clienti acquisiti", @stage_counts["won"]], + ["Persi", @stage_counts["lost"]], + ["Valore WON", format_money(@metrics.won_value)], + ["Pipeline aperta", format_money(@metrics.open_pipeline_value)], + ["Task scaduti", @metrics.overdue_tasks_count] + ].each do |label, value| %> +
+
<%= label %>
+
<%= value %>
+
+ <% end %> +

Funnel commerciale

+

Numero grande: in questo stage ora. In piccolo: arrivati almeno a questo punto (stage attuale + successivi, esclusi i persi).

- <% @funnel.each_with_index do |step, idx| %> - <% if step[:rate] %> -
↓ <%= step[:rate] %>%
+ <% @funnel.each do |step| %> + <% if step[:conversion_label] %> +
<%= step[:conversion_label] %> (<%= step[:rate] %>%)
<% end %>
<%= step[:label].upcase %> - <%= step[:count] %> + + <%= step[:current] %> + <%= step[:reached] %> da qui in poi +
<% end %>
@@ -79,10 +87,10 @@
Giorni medi contatto → WON
<%= @metrics.avg_days_to_won || "—" %>
Giorni medi dall'ultima attività
<%= @metrics.avg_days_since_last_activity || "—" %>
-
Prospect senza attività > 7gg
<%= @metrics.prospects_without_activity_over_7_days %>
-
Opp. senza next action
<%= @metrics.opportunities_without_next_action %>
-
Demo/Trial → WON
<%= @metrics.demo_to_won_conversion %>%
-
First Use → WON
<%= @metrics.first_use_to_won_conversion %>%
+
Senza attività in timeline da > 7 giorni
<%= @metrics.prospects_without_activity_over_7_days %>
+
In trattativa senza next action
<%= @metrics.opportunities_without_next_action %>
+
Demo/Trial → WON
<%= format_conversion_rate(@metrics.demo_to_won_conversion) %>
+
First Use → WON
<%= format_conversion_rate(@metrics.first_use_to_won_conversion) %>
ARPA (WON)
<%= format_money(@metrics.arpa) %>
Pipeline value
<%= format_money(@metrics.open_pipeline_value) %>
@@ -111,40 +119,40 @@
-

Interessati senza follow-up

+

Interessati senza follow-up (<%= @attention[:interested_without_followup][:total] %>)

Hanno detto sì in linea: manca la prossima chiamata/email in agenda.

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

Opportunità ferme da oltre 7 giorni

+

Opportunità ferme da oltre 7 giorni (<%= @attention[:stalled_opportunities][:total] %>)

Lo stage non si è mosso: va rilanciato o chiuso come perso.

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

Task scaduti

+

Task scaduti (<%= @attention[:overdue_tasks][:total] %>)

Promemoria già passati: completarli o ripianificarli.

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

Senza contatto

+

Senza contatto (<%= @attention[:organizations_without_contacts][:total] %>)

Manca una persona a cui scrivere o telefonare.

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

Senza valore (già in trattativa)

+

Senza valore (già in trattativa) (<%= @attention[:opportunities_without_value][:total] %>)

Dopo il primo contatto serve un importo stimato (Light/Full…) per la pipeline.

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

Senza prossima azione (già in trattativa)

+

Senza prossima azione (già in trattativa) (<%= @attention[:opportunities_without_next_action][:total] %>)

Dopo l’invio o la risposta manca un task in agenda: la trattativa si ferma.

- <%= 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." %>
diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb index 86719d3..ec072d1 100644 --- a/app/views/reports/index.html.erb +++ b/app/views/reports/index.html.erb @@ -8,22 +8,35 @@ Stage - Count + In questo stage + Da qui in poi - <% @funnel[:stages].each do |s| %> + <% @funnel[:funnel].each do |step| %> - <%= s[:label] %> - <%= s[:count] %> + <%= step[:label] %> + <%= step[:current] %> + <%= step[:reached] %> + + <% end %> + <% lost = @funnel[:stages].find { |s| s[:stage] == "lost" } %> + <% if lost %> + + <%= lost[:label] %> + <%= lost[:count] %> + — <% end %> +

In questo stage = foto attuale. Da qui in poi = stage attuale + successivi, esclusi i persi.

<% @funnel[:funnel].each do |step| %> -
<%= step[:label] %>: <%= step[:count] %><% if step[:rate] %> (<%= step[:rate] %>% dal passo precedente)<% end %>
+ <% if step[:conversion_label] %> +
<%= step[:conversion_label] %> (<%= step[:rate] %>%)
+ <% end %> <% end %>
diff --git a/test/controllers/dashboard_controller_test.rb b/test/controllers/dashboard_controller_test.rb new file mode 100644 index 0000000..e186f69 --- /dev/null +++ b/test/controllers/dashboard_controller_test.rb @@ -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 diff --git a/test/models/dashboard_metrics_test.rb b/test/models/dashboard_metrics_test.rb index b02af82..d3b1580 100644 --- a/test/models/dashboard_metrics_test.rb +++ b/test/models/dashboard_metrics_test.rb @@ -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