Commit iniziale di eminuxCRM: CRM Rails con pipeline, campagne email e Docker.
Include autenticazione, progetti isolati, mail marketing HTML con SMTP, test A/B e editor WYSIWYG. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
class Dashboard::Metrics
|
||||
def initialize(scope: Opportunity.all, project: nil)
|
||||
@scope = scope
|
||||
@project = project
|
||||
end
|
||||
|
||||
def stage_counts
|
||||
@stage_counts ||= Catalog::PIPELINE_ORDER.index_with { |stage| @scope.where(pipeline_stage: stage).count }
|
||||
end
|
||||
|
||||
def prospect_count
|
||||
organizations_scope.prospects.count
|
||||
end
|
||||
|
||||
def open_pipeline_value
|
||||
@scope.open_stage.sum(:estimated_value).to_f
|
||||
end
|
||||
|
||||
def won_value
|
||||
@scope.won.sum(:estimated_value).to_f
|
||||
end
|
||||
|
||||
def conversion_rates
|
||||
stages = Catalog::OPEN_PIPELINE_STAGES + %w[won]
|
||||
rates = {}
|
||||
stages.each_cons(2) do |from, to|
|
||||
from_count = cumulative_reached(from)
|
||||
to_count = cumulative_reached(to)
|
||||
rates["#{from}_to_#{to}"] = percentage(to_count, from_count)
|
||||
end
|
||||
rates
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
end
|
||||
steps
|
||||
end
|
||||
|
||||
def avg_days_to_won
|
||||
records = @scope.won.where.not(first_contacted_at: nil, won_at: nil)
|
||||
return nil if records.empty?
|
||||
|
||||
total = records.sum { |o| ((o.won_at - o.first_contacted_at) / 1.day) }
|
||||
(total / records.size).round(1)
|
||||
end
|
||||
|
||||
def avg_days_since_last_activity
|
||||
orgs = organizations_scope.left_joins(:activities)
|
||||
.select("organizations.id, MAX(activities.happened_at) AS last_at")
|
||||
.group("organizations.id")
|
||||
.having("MAX(activities.happened_at) IS NOT NULL")
|
||||
return nil if orgs.empty?
|
||||
|
||||
days = orgs.map { |o| ((Time.current - o.last_at) / 1.day) }
|
||||
(days.sum / days.size).round(1)
|
||||
end
|
||||
|
||||
def prospects_without_activity_over_7_days
|
||||
organizations_scope.prospects
|
||||
.left_joins(:activities)
|
||||
.group("organizations.id")
|
||||
.having("MAX(activities.happened_at) IS NULL OR MAX(activities.happened_at) < ?", 7.days.ago)
|
||||
.count
|
||||
.size
|
||||
end
|
||||
|
||||
def overdue_tasks_count
|
||||
tasks_scope.overdue.count
|
||||
end
|
||||
|
||||
def opportunities_without_next_action
|
||||
opportunities_missing_next_action.count
|
||||
end
|
||||
|
||||
def demo_to_won_conversion
|
||||
demo = reached_or_beyond("demo_trial")
|
||||
percentage(@scope.won.count, demo)
|
||||
end
|
||||
|
||||
def first_use_to_won_conversion
|
||||
first_use = reached_or_beyond("first_use")
|
||||
percentage(@scope.won.count, first_use)
|
||||
end
|
||||
|
||||
def arpa
|
||||
won = @scope.won.where.not(estimated_value: nil)
|
||||
return 0 if won.empty?
|
||||
|
||||
(won.sum(:estimated_value).to_f / won.count).round(2)
|
||||
end
|
||||
|
||||
def attention_items
|
||||
{
|
||||
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)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def organizations_scope
|
||||
@project ? Organization.for_project(@project) : Organization.all
|
||||
end
|
||||
|
||||
def tasks_scope
|
||||
@project ? Task.for_project(@project) : Task.all
|
||||
end
|
||||
|
||||
def cumulative_reached(stage)
|
||||
reached_or_beyond(stage)
|
||||
end
|
||||
|
||||
def reached_or_beyond(stage)
|
||||
idx = Catalog::PIPELINE_ORDER.index(stage)
|
||||
return 0 unless idx
|
||||
|
||||
stages = Catalog::PIPELINE_ORDER[idx..] - ["lost"]
|
||||
@scope.where(pipeline_stage: stages).count
|
||||
end
|
||||
|
||||
def percentage(part, whole)
|
||||
return 0 if whole.to_i.zero?
|
||||
|
||||
((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)
|
||||
end
|
||||
|
||||
def stalled_opportunities
|
||||
@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)
|
||||
end
|
||||
|
||||
def advanced_open_stage
|
||||
@scope.open_stage.where.not(pipeline_stage: "to_contact")
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user