Init version of dashboard stats.
This commit is contained in:
parent
f527a058c6
commit
aee48e22c2
14 changed files with 729 additions and 157 deletions
|
@ -0,0 +1,106 @@
|
||||||
|
class App.DashboardStats extends App.Controller
|
||||||
|
constructor: ->
|
||||||
|
super
|
||||||
|
|
||||||
|
stats_store = App.StatsStore.first()
|
||||||
|
if stats_store
|
||||||
|
@render(stats_store.data)
|
||||||
|
else
|
||||||
|
@render()
|
||||||
|
|
||||||
|
# bind to rebuild view event
|
||||||
|
@bind('dashboard_stats_rebuild', @render)
|
||||||
|
|
||||||
|
render: (data = {}) ->
|
||||||
|
if !data.StatsTicketWaitingTime
|
||||||
|
data.StatsTicketWaitingTime =
|
||||||
|
handling_time: 0
|
||||||
|
average: 0
|
||||||
|
if !data.StatsTicketEscalation
|
||||||
|
data.StatsTicketEscalation =
|
||||||
|
state: 'supergood'
|
||||||
|
own: 0
|
||||||
|
total: 0
|
||||||
|
if !data.StatsTicketChannelDistribution
|
||||||
|
data.StatsTicketChannelDistribution =
|
||||||
|
channels:
|
||||||
|
1:
|
||||||
|
inbound: 0
|
||||||
|
outbound: 0
|
||||||
|
inbound_in_percent: 0
|
||||||
|
outbound_in_percent: 0
|
||||||
|
2:
|
||||||
|
inbound: 0
|
||||||
|
outbound: 0
|
||||||
|
inbound_in_percent: 0
|
||||||
|
outbound_in_percent: 0
|
||||||
|
3:
|
||||||
|
inbound: 0
|
||||||
|
outbound: 0
|
||||||
|
inbound_in_percent: 0
|
||||||
|
outbound_in_percent: 0
|
||||||
|
if !data.StatsTicketLoadMeasure
|
||||||
|
data.StatsTicketLoadMeasure =
|
||||||
|
state: 'supergood'
|
||||||
|
percent: 0
|
||||||
|
own: 0
|
||||||
|
total: 0
|
||||||
|
average: 0
|
||||||
|
if !data.StatsTicketInProcess
|
||||||
|
data.StatsTicketInProcess =
|
||||||
|
state: 'supergood'
|
||||||
|
percent: 0
|
||||||
|
average_percent: 0
|
||||||
|
if !data.StatsTicketReopen
|
||||||
|
data.StatsTicketReopen =
|
||||||
|
state: 'supergood'
|
||||||
|
percent: 0
|
||||||
|
average_percent: 0
|
||||||
|
|
||||||
|
@html App.view('dashboard/stats')(data)
|
||||||
|
|
||||||
|
if data.StatsTicketWaitingTime
|
||||||
|
@renderWidgetClockFace data.StatsTicketWaitingTime.handling_time
|
||||||
|
|
||||||
|
renderWidgetClockFace: (time) =>
|
||||||
|
canvas = @el.find 'canvas'
|
||||||
|
ctx = canvas.get(0).getContext '2d'
|
||||||
|
radius = 26
|
||||||
|
|
||||||
|
@el.find('.time.stat-widget .stat-amount').text time
|
||||||
|
|
||||||
|
canvas.attr 'width', 2 * radius
|
||||||
|
canvas.attr 'height', 2 * radius
|
||||||
|
|
||||||
|
time = 60 if time > 60
|
||||||
|
|
||||||
|
handlingTimeColors =
|
||||||
|
5: '#38AE6A' # supergood
|
||||||
|
10: '#A9AC41' # good
|
||||||
|
15: '#FAAB00' # ok
|
||||||
|
20: '#F6820B' # bad
|
||||||
|
25: '#F35910' # superbad
|
||||||
|
|
||||||
|
for handlingTime, timeColor of handlingTimeColors
|
||||||
|
if time <= handlingTime
|
||||||
|
backgroundColor = timeColor
|
||||||
|
break
|
||||||
|
|
||||||
|
# 30% background
|
||||||
|
ctx.globalAlpha = 0.3
|
||||||
|
ctx.fillStyle = backgroundColor
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc radius, radius, radius, 0, Math.PI * 2, true
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
# 100% pie piece
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo radius, radius
|
||||||
|
arcsector = Math.PI * 2 * time/60
|
||||||
|
ctx.arc radius, radius, radius, -Math.PI/2, arcsector - Math.PI/2, false
|
||||||
|
ctx.lineTo radius, radius
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
|
@ -7,7 +7,7 @@ class App.Dashboard extends App.Controller
|
||||||
super
|
super
|
||||||
|
|
||||||
if @isRole('Customer')
|
if @isRole('Customer')
|
||||||
@navigate '#'
|
@navigate '#', true
|
||||||
return
|
return
|
||||||
|
|
||||||
# render page
|
# render page
|
||||||
|
@ -25,62 +25,21 @@ class App.Dashboard extends App.Controller
|
||||||
isAdmin: @isRole('Admin')
|
isAdmin: @isRole('Admin')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
new App.DashboardStats(
|
||||||
|
el: @$('.stat-widgets')
|
||||||
|
)
|
||||||
|
|
||||||
new App.DashboardActivityStream(
|
new App.DashboardActivityStream(
|
||||||
el: @$('.sidebar')
|
el: @$('.sidebar')
|
||||||
limit: 25
|
limit: 25
|
||||||
)
|
)
|
||||||
|
|
||||||
@renderWidgetClockFace 25
|
|
||||||
|
|
||||||
clues: (e) =>
|
clues: (e) =>
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
new App.FirstStepsClues(
|
new App.FirstStepsClues(
|
||||||
el: @el
|
el: @el
|
||||||
)
|
)
|
||||||
|
|
||||||
renderWidgetClockFace: (time) =>
|
|
||||||
canvas = @el.find 'canvas'
|
|
||||||
ctx = canvas.get(0).getContext '2d'
|
|
||||||
radius = 26
|
|
||||||
|
|
||||||
@el.find('.time.stat-widget .stat-amount').text time
|
|
||||||
|
|
||||||
canvas.attr 'width', 2 * radius
|
|
||||||
canvas.attr 'height', 2 * radius
|
|
||||||
|
|
||||||
time = 60 if time > 60
|
|
||||||
|
|
||||||
handlingTimeColors =
|
|
||||||
5: '#38AE6A' # supergood
|
|
||||||
10: '#A9AC41' # good
|
|
||||||
15: '#FAAB00' # ok
|
|
||||||
20: '#F6820B' # bad
|
|
||||||
25: '#F35910' # superbad
|
|
||||||
|
|
||||||
for handlingTime, timeColor of handlingTimeColors
|
|
||||||
if time <= handlingTime
|
|
||||||
backgroundColor = timeColor
|
|
||||||
break
|
|
||||||
|
|
||||||
# 30% background
|
|
||||||
ctx.globalAlpha = 0.3
|
|
||||||
ctx.fillStyle = backgroundColor
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc radius, radius, radius, 0, Math.PI * 2, true
|
|
||||||
ctx.closePath()
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
# 100% pie piece
|
|
||||||
ctx.globalAlpha = 1
|
|
||||||
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo radius, radius
|
|
||||||
arcsector = Math.PI * 2 * time/60
|
|
||||||
ctx.arc radius, radius, radius, -Math.PI/2, arcsector - Math.PI/2, false
|
|
||||||
ctx.lineTo radius, radius
|
|
||||||
ctx.closePath()
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
active: (state) =>
|
active: (state) =>
|
||||||
@activeState = state
|
@activeState = state
|
||||||
|
|
||||||
|
|
|
@ -33,117 +33,7 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-content stat-widgets three-columns horizontal">
|
<div class="tab-content stat-widgets three-columns horizontal"></div>
|
||||||
<div class="column">
|
|
||||||
<div class="time stat-widget vertical">
|
|
||||||
<h3>∅ Waiting time today</h3>
|
|
||||||
<div class="stat-graphic">
|
|
||||||
<div class="stat-stopwatch centered">
|
|
||||||
<svg class="stat-icon stopwatch-icon"><use xlink:href="#icon-stopwatch" /></svg>
|
|
||||||
<canvas class="stat-dial"></canvas>
|
|
||||||
<div class="stat-amount"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-label">My handling time: 25 minutes</div>
|
|
||||||
<div class="stat-detail">Average: 13%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<div class="mood stat-widget vertical">
|
|
||||||
<h3>Mood</h3>
|
|
||||||
<div class="stat-graphic">
|
|
||||||
<svg class="stat-icon mood-icon"><use xlink:href="#icon-mood-supergood" /></svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-label">3% of my tickets escalated.</div>
|
|
||||||
<div class="stat-detail">Average: 17%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<div class="channel-distribution stat-widget vertical centered">
|
|
||||||
<h3>Channel Distribution</h3>
|
|
||||||
<div class="stat-graphic">
|
|
||||||
<div class="stats-row email-channel">
|
|
||||||
<svg class="stat-channel-icon"><use xlink:href="#icon-email" /></svg>
|
|
||||||
<div class="stat-bars">
|
|
||||||
<div class="stat-bar primary" style="height: 80%"></div>
|
|
||||||
<div class="stat-bar secondary" style="height: 100%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-label">34%</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-row received-calls-channel">
|
|
||||||
<svg class="stat-channel-icon"><use xlink:href="#icon-received-calls" /></svg>
|
|
||||||
<div class="stat-bars">
|
|
||||||
<div class="stat-bar primary" style="height: 53%"></div>
|
|
||||||
<div class="stat-bar secondary" style="height: 47%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-label">26%</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-row outbound-calls-channel">
|
|
||||||
<svg class="stat-channel-icon"><use xlink:href="#icon-outbound-calls" /></svg>
|
|
||||||
<div class="stat-bars">
|
|
||||||
<div class="stat-bar primary" style="height: 46%"></div>
|
|
||||||
<div class="stat-bar secondary" style="height: 53%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-label">24%</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-row facebook-channel">
|
|
||||||
<svg class="stat-channel-icon"><use xlink:href="#icon-facebook" /></svg>
|
|
||||||
<div class="stat-bars">
|
|
||||||
<div class="stat-bar primary" style="height: 24%"></div>
|
|
||||||
<div class="stat-bar secondary" style="height: 18%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-label">12%</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-row twitter-channel">
|
|
||||||
<svg class="stat-channel-icon"><use xlink:href="#icon-twitter" /></svg>
|
|
||||||
<div class="stat-bars">
|
|
||||||
<div class="stat-bar primary" style="height: 13%"></div>
|
|
||||||
<div class="stat-bar secondary" style="height: 16%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-label">4%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<div class="status stat-widget vertical">
|
|
||||||
<h3>Status</h3>
|
|
||||||
<div class="stat-graphic">
|
|
||||||
<div class="stat-tickets vertical reverse end">
|
|
||||||
<svg class="one-ticket state-color supergood-state"><use xlink:href="#icon-one-ticket" /></svg>
|
|
||||||
<svg class="one-ticket state-color supergood-state"><use xlink:href="#icon-one-ticket" /></svg>
|
|
||||||
<svg class="one-ticket state-color supergood-state"><use xlink:href="#icon-one-ticket" /></svg>
|
|
||||||
<svg class="one-ticket state-color supergood-state"><use xlink:href="#icon-one-ticket" /></svg>
|
|
||||||
<svg class="one-ticket state-color supergood-state"><use xlink:href="#icon-one-ticket" /></svg>
|
|
||||||
<svg class="one-ticket state-color supergood-state"><use xlink:href="#icon-one-ticket" /></svg>
|
|
||||||
</div>
|
|
||||||
<svg class="total-tickets"><use xlink:href="#icon-total-tickets" /></svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-label">Tickets of my Group: 78 of 234</div>
|
|
||||||
<div class="stat-detail">Average: 22%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<div class="in-process stat-widget vertical">
|
|
||||||
<h3>Tickets in process</h3>
|
|
||||||
<div class="stat-graphic">
|
|
||||||
<svg class="in-process-icon state-color supergood-state"><use xlink:href="#icon-in-process" /></svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-label">74% are currently in process</div>
|
|
||||||
<div class="stat-detail">Average: 62%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<div class="reopening stat-widget vertical">
|
|
||||||
<h3>Reopening rate</h3>
|
|
||||||
<div class="stat-graphic">
|
|
||||||
<svg class="reopening-icon state-color ok-state"><use xlink:href="#icon-reopening" /></svg>
|
|
||||||
</div>
|
|
||||||
<div class="stat-label">7% are being reopened</div>
|
|
||||||
<div class="stat-detail">Average: 6%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar optional">
|
<div class="sidebar optional">
|
||||||
|
|
77
app/assets/javascripts/app/views/dashboard/stats.jst.eco
Normal file
77
app/assets/javascripts/app/views/dashboard/stats.jst.eco
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
<div class="column">
|
||||||
|
<div class="time stat-widget vertical">
|
||||||
|
<h3><%- @T('∅ Waiting time today') %></h3>
|
||||||
|
<div class="stat-graphic">
|
||||||
|
<div class="stat-stopwatch centered">
|
||||||
|
<%- @Icon('stopwatch', 'stat-icon stopwatch-icon') %>
|
||||||
|
<canvas class="stat-dial"></canvas>
|
||||||
|
<div class="stat-amount"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-label"><%- @T('My handling time: %s minutes', @StatsTicketWaitingTime.handling_time) %></div>
|
||||||
|
<div class="stat-detail"><%- @T('Average: %s minutes', @StatsTicketWaitingTime.average) %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="mood stat-widget vertical">
|
||||||
|
<h3><%- @T('Mood') %></h3>
|
||||||
|
<div class="stat-graphic">
|
||||||
|
<%- @Icon("mood-#{@StatsTicketEscalation.state}", 'stat-icon mood-icon') %>
|
||||||
|
</div>
|
||||||
|
<div class="stat-label"><%- @StatsTicketEscalation.own %> of my tickets escalated.</div>
|
||||||
|
<div class="stat-detail"><%- @T('Total: %s', @StatsTicketEscalation.total) %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="channel-distribution stat-widget vertical centered">
|
||||||
|
<h3><%- @T('Channel Distribution') %></h3>
|
||||||
|
<div class="stat-graphic">
|
||||||
|
<% for channel_name, channel of @StatsTicketChannelDistribution.channels: %>
|
||||||
|
<div class="stats-row email-channel">
|
||||||
|
<%- @Icon(channel.icon, 'stat-channel-icon') %>
|
||||||
|
<div class="stat-bars">
|
||||||
|
<div class="stat-bar primary" style="height: <%- channel.inbound_in_percent %>%" title="<%- @T('Inbound') %>: <%- channel.inbound_in_percent %>% (<%- channel.inbound %>)"></div>
|
||||||
|
<div class="stat-bar secondary" style="height: <%- channel.outbound_in_percent %>%" title="<%- @T('Outbound') %>: <%- channel.outbound_in_percent %>% (<%- channel.outbound %>)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-label"></div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="status stat-widget vertical">
|
||||||
|
<h3><%- @T('Assigned') %></h3>
|
||||||
|
<div class="stat-graphic">
|
||||||
|
<div class="stat-tickets vertical reverse end">
|
||||||
|
<% stack_counter = parseInt(@StatsTicketLoadMeasure.percent*0.16) %>
|
||||||
|
<% for count in [1..stack_counter]: %>
|
||||||
|
<%- @Icon('one-ticket', "one-ticket state-color #{@StatsTicketLoadMeasure.state}-state") %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<%- @Icon('total-tickets', 'total-tickets') %>
|
||||||
|
</div>
|
||||||
|
<div class="stat-label"><%- @T('Tickets assigned to me: %s of %s', @StatsTicketLoadMeasure.own, @StatsTicketLoadMeasure.total) %></div>
|
||||||
|
<div class="stat-detail"><%- @T('Average: %s', @StatsTicketLoadMeasure.average) %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="in-process stat-widget vertical">
|
||||||
|
<h3><%- @T('Tickets in process') %></h3>
|
||||||
|
<div class="stat-graphic">
|
||||||
|
<%- @Icon('in-process', "in-process-icon state-color #{@StatsTicketInProcess.state}-state") %>
|
||||||
|
</div>
|
||||||
|
<div class="stat-label"><%- @T('%s% are currently in process', @StatsTicketInProcess.percent) %></div>
|
||||||
|
<div class="stat-detail"><%- @T('Average: %s%', @StatsTicketInProcess.average_percent) %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="reopening stat-widget vertical">
|
||||||
|
<h3><%- @T('Reopening rate') %></h3>
|
||||||
|
<div class="stat-graphic">
|
||||||
|
<%- @Icon('reopening', "reopening-icon state-color #{@StatsTicketReopen.state}-state") %>
|
||||||
|
</div>
|
||||||
|
<div class="stat-label"><%- @T('%s% are being reopened', @StatsTicketReopen.percent) %></div>
|
||||||
|
<div class="stat-detail"><%- @T('Average: %s%', @StatsTicketReopen.average_percent) %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
134
app/models/stats_store.rb
Normal file
134
app/models/stats_store.rb
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
# Copyright (C) 2012-2014 Zammad Foundation, http://zammad-foundation.org/
|
||||||
|
|
||||||
|
class StatsStore < ApplicationModel
|
||||||
|
store :data
|
||||||
|
|
||||||
|
=begin
|
||||||
|
|
||||||
|
count = StatsStore.count_by_search(
|
||||||
|
object: 'User',
|
||||||
|
o_id: ticket.owner_id,
|
||||||
|
key: 'ticket:reopen',
|
||||||
|
start: Time.zone.now - 7.days,
|
||||||
|
end: Time.zone.now,
|
||||||
|
)
|
||||||
|
|
||||||
|
=end
|
||||||
|
|
||||||
|
def self.count_by_search(data)
|
||||||
|
|
||||||
|
# lookups
|
||||||
|
if data[:object]
|
||||||
|
object_id = ObjectLookup.by_name( data[:object] )
|
||||||
|
end
|
||||||
|
|
||||||
|
StatsStore.where(stats_store_object_id: object_id, o_id: data[:o_id], key: data[:key])
|
||||||
|
.where('created_at < ? AND created_at > ?', data[:start], data[:end]).count
|
||||||
|
end
|
||||||
|
|
||||||
|
=begin
|
||||||
|
|
||||||
|
item = StatsStore.search(
|
||||||
|
object: 'User',
|
||||||
|
o_id: current_user.id,
|
||||||
|
key: 'dashboard',
|
||||||
|
)
|
||||||
|
|
||||||
|
=end
|
||||||
|
|
||||||
|
def self.search(data)
|
||||||
|
|
||||||
|
# lookups
|
||||||
|
if data[:object]
|
||||||
|
data[:stats_store_object_id] = ObjectLookup.by_name( data[:object] )
|
||||||
|
data.delete(:object)
|
||||||
|
end
|
||||||
|
|
||||||
|
find_by(data)
|
||||||
|
end
|
||||||
|
|
||||||
|
=begin
|
||||||
|
|
||||||
|
item = StatsStore.snyc(
|
||||||
|
object: 'User',
|
||||||
|
o_id: current_user.id,
|
||||||
|
key: 'dashboard',
|
||||||
|
data: {some data},
|
||||||
|
)
|
||||||
|
|
||||||
|
=end
|
||||||
|
|
||||||
|
def self.sync(params)
|
||||||
|
|
||||||
|
data = params[:data]
|
||||||
|
params.delete(:data)
|
||||||
|
|
||||||
|
item = search(params)
|
||||||
|
|
||||||
|
if item
|
||||||
|
item.data = data
|
||||||
|
item.save
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
# lookups
|
||||||
|
if data[:object]
|
||||||
|
data[:stats_store_object_id] = ObjectLookup.by_name( data[:object] )
|
||||||
|
data.delete(:object)
|
||||||
|
end
|
||||||
|
|
||||||
|
params[:data] = data
|
||||||
|
params[:created_by_id] = 1
|
||||||
|
create(params)
|
||||||
|
end
|
||||||
|
|
||||||
|
=begin
|
||||||
|
|
||||||
|
StatsStore.add(
|
||||||
|
object: 'User',
|
||||||
|
o_id: ticket.owner_id,
|
||||||
|
key: 'ticket:reopen',
|
||||||
|
data: { ticket_id: ticket.id },
|
||||||
|
created_at: Time.zone.now,
|
||||||
|
)
|
||||||
|
|
||||||
|
=end
|
||||||
|
|
||||||
|
def self.add(data)
|
||||||
|
|
||||||
|
# lookups
|
||||||
|
if data[:object]
|
||||||
|
object_id = ObjectLookup.by_name( data[:object] )
|
||||||
|
end
|
||||||
|
|
||||||
|
# create history
|
||||||
|
record = {
|
||||||
|
stats_store_object_id: object_id,
|
||||||
|
o_id: data[:o_id],
|
||||||
|
key: data[:key],
|
||||||
|
data: data[:data],
|
||||||
|
created_at: data[:created_at],
|
||||||
|
created_by_id: data[:created_by_id],
|
||||||
|
}
|
||||||
|
|
||||||
|
StatsStore.create(record)
|
||||||
|
end
|
||||||
|
|
||||||
|
=begin
|
||||||
|
|
||||||
|
cleanup old stats store
|
||||||
|
|
||||||
|
StatsStore.cleanup
|
||||||
|
|
||||||
|
optional you can parse the max oldest stats store entries
|
||||||
|
|
||||||
|
StatsStore.cleanup(3.months)
|
||||||
|
|
||||||
|
=end
|
||||||
|
|
||||||
|
def self.cleanup(diff = 3.months)
|
||||||
|
StatsStore.where('created_at < ?', Time.zone.now - diff).delete_all
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
43
db/migrate/20150967000001_create_stats_store.rb
Normal file
43
db/migrate/20150967000001_create_stats_store.rb
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
class CreateStatsStore < ActiveRecord::Migration
|
||||||
|
def up
|
||||||
|
create_table :stats_stores do |t|
|
||||||
|
t.references :stats_store_object, null: false
|
||||||
|
t.integer :o_id, null: false
|
||||||
|
t.string :key, limit: 250, null: true
|
||||||
|
t.integer :related_o_id, null: true
|
||||||
|
t.integer :related_stats_store_object_id, null: true
|
||||||
|
t.string :data, limit: 2500, null: true
|
||||||
|
t.integer :created_by_id, null: false
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
add_index :stats_stores, [:o_id]
|
||||||
|
add_index :stats_stores, [:key]
|
||||||
|
add_index :stats_stores, [:stats_store_object_id]
|
||||||
|
add_index :stats_stores, [:created_by_id]
|
||||||
|
add_index :stats_stores, [:created_at]
|
||||||
|
|
||||||
|
|
||||||
|
Scheduler.create_or_update(
|
||||||
|
name: 'Generate user based stats.',
|
||||||
|
method: 'Stats.generate',
|
||||||
|
period: 11.minutes,
|
||||||
|
prio: 2,
|
||||||
|
active: true,
|
||||||
|
updated_by_id: 1,
|
||||||
|
created_by_id: 1,
|
||||||
|
)
|
||||||
|
Scheduler.create_or_update(
|
||||||
|
name: 'Delete old stats store entries.',
|
||||||
|
method: 'StatsStore.cleanup',
|
||||||
|
period: 31.days,
|
||||||
|
prio: 2,
|
||||||
|
active: true,
|
||||||
|
updated_by_id: 1,
|
||||||
|
created_by_id: 1,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
drop_table :stats_stores
|
||||||
|
end
|
||||||
|
end
|
45
lib/stats.rb
Normal file
45
lib/stats.rb
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
|
||||||
|
|
||||||
|
require 'stats_store'
|
||||||
|
|
||||||
|
class Stats
|
||||||
|
|
||||||
|
=begin
|
||||||
|
|
||||||
|
generate stats for user
|
||||||
|
|
||||||
|
Stats.generate
|
||||||
|
|
||||||
|
returns
|
||||||
|
|
||||||
|
result = true # if generation was successfully
|
||||||
|
|
||||||
|
=end
|
||||||
|
|
||||||
|
def self.generate
|
||||||
|
|
||||||
|
backends = [
|
||||||
|
Stats::TicketChannelDistribution,
|
||||||
|
Stats::TicketInProcess,
|
||||||
|
Stats::TicketLoadMeasure,
|
||||||
|
Stats::TicketEscalation,
|
||||||
|
Stats::TicketReopen,
|
||||||
|
]
|
||||||
|
|
||||||
|
users = User.of_role('Agent')
|
||||||
|
users.each {|user|
|
||||||
|
data = {}
|
||||||
|
backends.each {|backend|
|
||||||
|
data[backend.to_app_model] = backend.generate(user)
|
||||||
|
}
|
||||||
|
StatsStore.sync(
|
||||||
|
object: 'User',
|
||||||
|
o_id: user.id,
|
||||||
|
key: 'dashboard',
|
||||||
|
data: data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
89
lib/stats/ticket_channel_distribution.rb
Normal file
89
lib/stats/ticket_channel_distribution.rb
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
|
||||||
|
|
||||||
|
class Stats::TicketChannelDistribution
|
||||||
|
|
||||||
|
def self.generate(user)
|
||||||
|
|
||||||
|
# which time range?
|
||||||
|
time_range = 7.days
|
||||||
|
|
||||||
|
# get users groups
|
||||||
|
group_ids = user.groups.map(&:id)
|
||||||
|
|
||||||
|
# get channels
|
||||||
|
channels = [
|
||||||
|
{
|
||||||
|
sender: 'email',
|
||||||
|
icon: 'email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sender: 'phone',
|
||||||
|
icon: 'phone',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sender: 'twitter',
|
||||||
|
icon: 'twitter',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sender: 'facebook',
|
||||||
|
icon: 'facebook',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# calcualte
|
||||||
|
result = {}
|
||||||
|
total_in = 0
|
||||||
|
total_out = 0
|
||||||
|
channels.each {|channel|
|
||||||
|
result[channel[:sender].to_sym] = {
|
||||||
|
icon: channel[:icon]
|
||||||
|
}
|
||||||
|
type_ids = []
|
||||||
|
Ticket::Article::Type.all.each {|type|
|
||||||
|
next if type.name !~ /^#{channel[:sender]}/i
|
||||||
|
type_ids.push type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
sender = Ticket::Article::Sender.lookup( name: 'Customer' )
|
||||||
|
count = Ticket.where(group_id: group_ids).joins(:articles).where(
|
||||||
|
ticket_articles: { sender_id: sender, type_id: type_ids }
|
||||||
|
).where(
|
||||||
|
'ticket_articles.created_at > ?', Time.zone.now - time_range
|
||||||
|
).count
|
||||||
|
result[channel[:sender].to_sym][:inbound] = count
|
||||||
|
total_in += count
|
||||||
|
|
||||||
|
sender = Ticket::Article::Sender.lookup( name: 'Agent' )
|
||||||
|
count = Ticket.where(group_id: group_ids).joins(:articles).where(
|
||||||
|
ticket_articles: { sender_id: sender, type_id: type_ids }
|
||||||
|
).where(
|
||||||
|
'ticket_articles.created_at > ?', Time.zone.now - time_range
|
||||||
|
).count
|
||||||
|
result[channel[:sender].to_sym][:outbound] = count
|
||||||
|
total_out += count
|
||||||
|
}
|
||||||
|
|
||||||
|
# append in percent
|
||||||
|
channels.each {|channel|
|
||||||
|
count = result[channel[:sender].to_sym][:inbound]
|
||||||
|
#puts "#{channel.inspect}:in/#{result.inspect}:#{count}"
|
||||||
|
if count == 0
|
||||||
|
in_process_precent = 0
|
||||||
|
else
|
||||||
|
in_process_precent = (count * 1000) / ((total_in * 1000) / 100)
|
||||||
|
end
|
||||||
|
result[channel[:sender].to_sym][:inbound_in_percent] = in_process_precent
|
||||||
|
|
||||||
|
count = result[channel[:sender].to_sym][:outbound]
|
||||||
|
if count == 0
|
||||||
|
out_process_precent = 0
|
||||||
|
else
|
||||||
|
out_process_precent = (count * 1000) / ((total_out * 1000) / 100)
|
||||||
|
end
|
||||||
|
result[channel[:sender].to_sym][:outbound_in_percent] = out_process_precent
|
||||||
|
}
|
||||||
|
|
||||||
|
{ channels: result }
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
48
lib/stats/ticket_escalation.rb
Normal file
48
lib/stats/ticket_escalation.rb
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
|
||||||
|
|
||||||
|
class Stats::TicketEscalation
|
||||||
|
|
||||||
|
def self.generate(user)
|
||||||
|
|
||||||
|
# get users groups
|
||||||
|
group_ids = user.groups.map(&:id)
|
||||||
|
|
||||||
|
# owned tickets
|
||||||
|
own_escalated = Ticket.where(
|
||||||
|
owner_id: user.id,
|
||||||
|
group_id: group_ids,
|
||||||
|
escalation_time: Time.zone.now,
|
||||||
|
state_id: Ticket::State.by_category('open').map(&:id)
|
||||||
|
).count
|
||||||
|
|
||||||
|
# all tickets
|
||||||
|
all_escalated = Ticket.where(
|
||||||
|
owner_id: user.id,
|
||||||
|
group_id: group_ids,
|
||||||
|
escalation_time: Time.zone.now,
|
||||||
|
state_id: Ticket::State.by_category('open').map(&:id)
|
||||||
|
).count
|
||||||
|
|
||||||
|
average = '-'
|
||||||
|
state = 'supergood'
|
||||||
|
# if in_process_precent > 80
|
||||||
|
# state = 'supergood'
|
||||||
|
# elsif in_process_precent > 60
|
||||||
|
# state = 'good'
|
||||||
|
# elsif in_process_precent > 40
|
||||||
|
# state = 'ok'
|
||||||
|
# elsif in_process_precent > 20
|
||||||
|
# state = 'bad'
|
||||||
|
# elsif in_process_precent > 5
|
||||||
|
# state = 'superbad'
|
||||||
|
# end
|
||||||
|
|
||||||
|
{
|
||||||
|
average: average,
|
||||||
|
state: state,
|
||||||
|
own: own_escalated,
|
||||||
|
total: all_escalated,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
45
lib/stats/ticket_in_process.rb
Normal file
45
lib/stats/ticket_in_process.rb
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
|
||||||
|
|
||||||
|
class Stats::TicketInProcess
|
||||||
|
|
||||||
|
def self.generate(user)
|
||||||
|
|
||||||
|
# get history entries of tickets worked on today
|
||||||
|
history_object = History::Object.lookup(name: 'Ticket')
|
||||||
|
|
||||||
|
own_ticket_ids = Ticket.select('id').where(owner_id: user.id).map(&:id)
|
||||||
|
|
||||||
|
count = History.select('DISTINCT(o_id)').where(
|
||||||
|
'histories.created_at >= ? AND histories.history_object_id = ? AND histories.created_by_id = ? AND histories.o_id IN (?)', Time.zone.now - 1.day, history_object.id, user.id, own_ticket_ids
|
||||||
|
).count
|
||||||
|
|
||||||
|
total = own_ticket_ids.count
|
||||||
|
in_process_precent = 0
|
||||||
|
state = 'supergood'
|
||||||
|
average_in_percent = '-'
|
||||||
|
|
||||||
|
if count != 0 && total != 0
|
||||||
|
in_process_precent = (count * 1000) / ((total * 1000) / 100)
|
||||||
|
if in_process_precent > 80
|
||||||
|
state = 'supergood'
|
||||||
|
elsif in_process_precent > 60
|
||||||
|
state = 'good'
|
||||||
|
elsif in_process_precent > 40
|
||||||
|
state = 'ok'
|
||||||
|
elsif in_process_precent > 20
|
||||||
|
state = 'bad'
|
||||||
|
elsif in_process_precent > 5
|
||||||
|
state = 'superbad'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
state: state,
|
||||||
|
in_process: count,
|
||||||
|
percent: in_process_precent,
|
||||||
|
average_percent: average_in_percent,
|
||||||
|
total: total,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
41
lib/stats/ticket_load_measure.rb
Normal file
41
lib/stats/ticket_load_measure.rb
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
|
||||||
|
|
||||||
|
class Stats::TicketLoadMeasure
|
||||||
|
|
||||||
|
def self.generate(user)
|
||||||
|
|
||||||
|
# owned tickets
|
||||||
|
count = Ticket.where(owner_id: user.id).count
|
||||||
|
|
||||||
|
# get total open
|
||||||
|
total = Ticket.where(group_id: user.groups.map(&:id), state_id: Ticket::State.by_category('open').map(&:id) ).count
|
||||||
|
|
||||||
|
average = '-'
|
||||||
|
state = 'good'
|
||||||
|
# if in_process_precent > 80
|
||||||
|
# state = 'supergood'
|
||||||
|
# elsif in_process_precent > 60
|
||||||
|
# state = 'good'
|
||||||
|
# elsif in_process_precent > 40
|
||||||
|
# state = 'ok'
|
||||||
|
# elsif in_process_precent > 20
|
||||||
|
# state = 'bad'
|
||||||
|
# elsif in_process_precent > 5
|
||||||
|
# state = 'superbad'
|
||||||
|
# end
|
||||||
|
|
||||||
|
if count > total
|
||||||
|
total = count
|
||||||
|
end
|
||||||
|
load_measure_precent = (count * 1000) / ((total * 1000) / 100)
|
||||||
|
|
||||||
|
{
|
||||||
|
average: average,
|
||||||
|
percent: load_measure_precent,
|
||||||
|
state: state,
|
||||||
|
own: count,
|
||||||
|
total: total,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
51
lib/stats/ticket_reopen.rb
Normal file
51
lib/stats/ticket_reopen.rb
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
|
||||||
|
|
||||||
|
class Stats::TicketReopen
|
||||||
|
|
||||||
|
def self.generate(user)
|
||||||
|
count = StatsStore.count_by_search(
|
||||||
|
object: 'User',
|
||||||
|
o_id: user.id,
|
||||||
|
key: 'ticket:reopen',
|
||||||
|
start: Time.zone.now - 7.days,
|
||||||
|
end: Time.zone.now,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
state: 'good',
|
||||||
|
own: count,
|
||||||
|
total: 0,
|
||||||
|
percent: 0,
|
||||||
|
average_percent: '-',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.log(object, o_id, changes, updated_by_id)
|
||||||
|
return if object != 'Ticket'
|
||||||
|
ticket = Ticket.lookup(id: o_id)
|
||||||
|
|
||||||
|
# check if close_time is already set / if not, ticket is not reopend
|
||||||
|
return if !ticket.close_time
|
||||||
|
|
||||||
|
return if !changes['state_id']
|
||||||
|
return if ticket.owner_id == 1
|
||||||
|
|
||||||
|
state_before = Ticket::State.lookup(id: changes['state_id'][0])
|
||||||
|
state_type_before = Ticket::StateType.lookup(id: state_before.state_type_id)
|
||||||
|
return if state_type_before.name != 'closed'
|
||||||
|
|
||||||
|
state_now = Ticket::State.lookup(id: changes['state_id'][1])
|
||||||
|
state_type_now = Ticket::StateType.lookup(id: state_now.state_type_id)
|
||||||
|
return if state_type_now.name == 'closed'
|
||||||
|
|
||||||
|
StatsStore.add(
|
||||||
|
object: 'User',
|
||||||
|
o_id: ticket.owner_id,
|
||||||
|
key: 'ticket:reopen',
|
||||||
|
data: { ticket_id: ticket.id },
|
||||||
|
created_at: Time.zone.now,
|
||||||
|
created_by_id: updated_by_id,
|
||||||
|
updated_by_id: updated_by_id,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
44
lib/stats/ticket_response_time.rb
Normal file
44
lib/stats/ticket_response_time.rb
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
# Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
|
||||||
|
|
||||||
|
class Stats::TicketResponseTime
|
||||||
|
|
||||||
|
def self.log(object, o_id)
|
||||||
|
return if object != 'Ticket'
|
||||||
|
|
||||||
|
ticket = Ticket.lookup(id: o_id)
|
||||||
|
|
||||||
|
article_created_by_id = 3
|
||||||
|
|
||||||
|
# check if response was sent by owner
|
||||||
|
return if ticket.owner_id != 1 && ticket.owner_id != article_created_by_id
|
||||||
|
|
||||||
|
# return if customer send at least
|
||||||
|
return if ticket.last_contact_customer > ticket.last_contact_agent
|
||||||
|
|
||||||
|
# TODO: only business hours
|
||||||
|
response_time_taken = ticket.last_contact_agent - ticket.last_contact_customer
|
||||||
|
|
||||||
|
(response_time_taken / 60).round
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.generate(user)
|
||||||
|
items = Stats.where('action_at > ? AND action_at < ?', Time.zone.now - 7.days, Time.zone.now).where(key: 'ticket:response_time')
|
||||||
|
count_total = items.count
|
||||||
|
total = 0
|
||||||
|
count_own = 0
|
||||||
|
own = 0
|
||||||
|
items.each {|_item|
|
||||||
|
ticket = Ticket.lookup(id: data[:ticket_id])
|
||||||
|
if ticket.owner_id == user.id
|
||||||
|
count_own += 1
|
||||||
|
own += data[:time]
|
||||||
|
end
|
||||||
|
total += data[:time]
|
||||||
|
}
|
||||||
|
{
|
||||||
|
own: (own / count_own).round,
|
||||||
|
total: (total / count_total).round,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
Loading…
Reference in a new issue