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 %>Conteggio per stage attuale: quante opportunità sono in quello stato ora.
+Numero grande: in questo stage ora. In piccolo: arrivati almeno a questo punto (stage attuale + successivi, esclusi i persi).
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." %>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 %>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 %>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 %>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." %>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." %>In questo stage = foto attuale. Da qui in poi = stage attuale + successivi, esclusi i persi.