Implemented issue #373 - Feature "Time recording”.

This commit is contained in:
Martin Edenhofer 2017-01-16 14:34:44 +01:00
parent 9cb57cdbe6
commit ad53128091
20 changed files with 685 additions and 10 deletions

View file

@ -428,6 +428,8 @@ class App.TicketZoom extends App.Controller
callback: @submit callback: @submit
task_key: @task_key task_key: @task_key
) )
#if @shown
# @attributeBar.start()
@form_id = App.ControllerForm.formId() @form_id = App.ControllerForm.formId()
@ -745,6 +747,35 @@ class App.TicketZoom extends App.Controller
ticket.article = article ticket.article = article
if !ticket.article
@submitPost(e, ticket)
return
# verify if time accounting is enabled
if @Config.get('time_accounting') isnt true
@submitPost(e, ticket)
return
# verify if time accounting is active for ticket
if false
@submitPost(e, ticket)
return
# time tracking
new App.TicketZoomTimeAccounting(
container: @el.closest('.content')
ticket: ticket
cancelCallback: =>
@formEnable(e)
submitCallback: (params) =>
if params.time_unit
ticket.article.time_unit = params.time_unit
@submitPost(e, ticket)
)
submitPost: (e, ticket) =>
# submit changes # submit changes
@ajax( @ajax(
id: "ticket_update_#{ticket.id}" id: "ticket_update_#{ticket.id}"

View file

@ -42,7 +42,7 @@ class App.TicketZoomSidebar extends App.ObserverController
render: (ticket) => render: (ticket) =>
editTicket = (el) => editTicket = (el) =>
el.append('<form><fieldset class="edit"></fieldset></form><div class="tags"></div><div class="links"></div>') el.append(App.view('ticket_zoom/sidebar_ticket')())
@edit = new Edit( @edit = new Edit(
object_id: ticket.id object_id: ticket.id
@ -52,7 +52,7 @@ class App.TicketZoomSidebar extends App.ObserverController
markForm: @markForm markForm: @markForm
) )
if !@permissionCheck('ticket.customer') if @permissionCheck('ticket.agent')
@tagWidget = new App.WidgetTag( @tagWidget = new App.WidgetTag(
el: @el.find('.tags') el: @el.find('.tags')
object_type: 'Ticket' object_type: 'Ticket'
@ -66,6 +66,11 @@ class App.TicketZoomSidebar extends App.ObserverController
links: @links links: @links
) )
@timeUnitWidget = new App.TicketZoomTimeUnit(
el: @el.find('.js-timeUnit')
object_id: ticket.id
)
showTicketHistory = => showTicketHistory = =>
new App.TicketHistory( new App.TicketHistory(
ticket_id: ticket.id ticket_id: ticket.id

View file

@ -0,0 +1,20 @@
class App.TicketZoomTimeAccounting extends App.ControllerModal
buttonClose: true
buttonCancel: 'skip'
buttonSubmit: 'Account Time'
buttonClass: 'btn--success'
head: 'Time Accounting'
small: true
content: ->
App.view('ticket_zoom/time_accounting')()
onCancel: =>
if @cancelCallback
@cancelCallback()
onSubmit: =>
@close()
if @submitCallback
params = @formParams()
@submitCallback(params)

View file

@ -0,0 +1,11 @@
class App.TicketZoomTimeUnit extends App.ObserverController
model: 'Ticket'
observe:
time_unit: true
render: (ticket) =>
return if !@permissionCheck('ticket.agent')
return if !ticket.time_unit
@html App.view('ticket_zoom/time_unit')(
ticket: ticket
)

View file

@ -0,0 +1,205 @@
class Index extends App.ControllerSubContent
requiredPermission: 'admin.time_accounting'
header: 'Time Accounting'
events:
'change .js-timeAccountingSetting input': 'setTimeAccounting'
'click .js-timePickerYear': 'setYear'
'click .js-timePickerMonth': 'setMonth'
elements:
'.js-timeAccountingSetting input': 'timeAccountingSetting'
constructor: ->
super
current = new Date()
currentDay = current.getDate()
currentMonth = current.getMonth() + 1
currentYear = current.getFullYear()
currentWeek = current.getWeek()
if !@month
@month = currentMonth
if !@year
@year = currentYear
@subscribeId = App.Setting.subscribe(@render, initFetch: true, clear: false)
release: =>
App.Setting.unsubscribe(@subscribeId)
render: =>
currentNewTagSetting = @Config.get('time_accounting') || false
#return if currentNewTagSetting is @lastNewTagSetting
@lastNewTagSetting = currentNewTagSetting
timeRangeYear = []
year = new Date().getFullYear()
for item in [year-2..year]
record = {
display: item
value: item
}
timeRangeYear.push record
timeRangeMonth = [
{
display: 'Jan'
value: 1
},
{
display: 'Feb'
value: 2
},
{
display: 'Mar'
value: 3
},
{
display: 'Apr'
value: 4,
},
{
display: 'Mai'
value: 5,
},
{
display: 'Jun'
value: 6,
},
{
display: 'Jul'
value: 7,
},
{
display: 'Aug'
value: 8,
},
{
display: 'Sep'
value: 9,
},
{
display: 'Oct'
value: 10,
},
{
display: 'Nov'
value: 11,
},
{
display: 'Dec'
value: 12,
},
]
@html App.view('time_accounting/index')(
timeRangeYear: timeRangeYear
timeRangeMonth: timeRangeMonth
year: @year
month: @month
)
configure_attributes = [
{ name: 'condition', display: 'Conditions for effected objects', tag: 'ticket_selector', null: false, preview: false, action: false, hasChanged: false },
]
new App.ControllerForm(
el: @$('.js-selector')
model:
configure_attributes: configure_attributes,
autofocus: true
)
new ByTicket(
el: @$('.js-tableTicket')
year: @year
month: @month
)
new ByCustomer(
el: @$('.js-tableCustomer')
year: @year
month: @month
)
new ByOrganization(
el: @$('.js-tableOrganization')
year: @year
month: @month
)
setTimeAccounting: (e) =>
value = @timeAccountingSetting.prop('checked')
App.Setting.set('time_accounting', value)
setYear: (e) =>
e.preventDefault()
@year = $(e.target).data('type')
@render()
setMonth: (e) =>
e.preventDefault()
@month = $(e.target).data('type')
@render()
class ByTicket extends App.Controller
constructor: ->
super
@load()
load: =>
@ajax(
id: 'by_ticket'
type: 'GET'
url: "#{@apiPath}/time_accounting/log/by_ticket/#{@year}/#{@month}"
processData: true
success: (data, status, xhr) =>
@render(data)
)
render: (rows) =>
@html App.view('time_accounting/by_ticket')(
rows: rows
)
class ByCustomer extends App.Controller
constructor: ->
super
@load()
load: =>
@ajax(
id: 'by_customer'
type: 'GET'
url: "#{@apiPath}/time_accounting/log/by_customer/#{@year}/#{@month}"
processData: true
success: (data, status, xhr) =>
@render(data)
)
render: (rows) =>
@html App.view('time_accounting/by_customer')(
rows: rows
)
class ByOrganization extends App.Controller
constructor: ->
super
@load()
load: =>
@ajax(
id: 'by_organization'
type: 'GET'
url: "#{@apiPath}/time_accounting/log/by_organization/#{@year}/#{@month}"
processData: true
success: (data, status, xhr) =>
@render(data)
)
render: (rows) =>
@html App.view('time_accounting/by_organization')(
rows: rows
)
App.Config.set('TimeAccounting', { prio: 8500, name: 'Time Accounting', parent: '#manage', target: '#manage/time_accounting', controller: Index, permission: ['admin.time_accounting'] }, 'NavBarAdmin')

View file

@ -1,5 +1,5 @@
class App.TicketArticle extends App.Model class App.TicketArticle extends App.Model
@configure 'TicketArticle', 'from', 'to', 'cc', 'subject', 'body', 'content_type', 'ticket_id', 'type_id', 'sender_id', 'internal', 'in_reply_to', 'form_id', 'preferences', 'updated_at' @configure 'TicketArticle', 'from', 'to', 'cc', 'subject', 'body', 'content_type', 'ticket_id', 'type_id', 'sender_id', 'internal', 'in_reply_to', 'form_id', 'time_unit', 'preferences', 'updated_at'
@extend Spine.Model.Ajax @extend Spine.Model.Ajax
@url: @apiPath + '/ticket_articles' @url: @apiPath + '/ticket_articles'
@configure_attributes = [ @configure_attributes = [

View file

@ -0,0 +1,6 @@
<form>
<fieldset class="edit"></fieldset>
</form>
<div class="tags"></div>
<div class="links"></div>
<div class="js-timeUnit"></div>

View file

@ -0,0 +1,3 @@
<form>
<input type="text" name="time_unit" placeholder="<%- @T('Please enter your time which you want to account.') %>"/>
</form>

View file

@ -0,0 +1,4 @@
<div>
<label><%- @T('Accounted Time') %></label>
<div><%= @ticket.time_unit %></div>
</div>

View file

@ -0,0 +1,23 @@
<% if !@rows.length: %>
<table class="settings-list settings-list--stretch settings-list--placeholder">
<thead><tr><th><%- @T('No Entries') %>
</table>
<% else: %>
<table class="table table-striped table-hover">
<thead>
<tr>
<th><%- @T('Customer') %>
<th><%- @T('Organization') %>
<th><%- @T('Time Units') %>
</thead>
<tbody>
<% for row in @rows: %>
<tr>
<td><a href="#user/profile/<%- row.customer.id %>"><%= row.customer.email %></a>
<td><% if row.organization: %><%= row.organization.name %><% end %>
<td><%= row.time_unit %>
<% end %>
</tbody>
</table>
</div>
<% end %>

View file

@ -0,0 +1,21 @@
<% if !@rows.length: %>
<table class="settings-list settings-list--stretch settings-list--placeholder">
<thead><tr><th><%- @T('No Entries') %>
</table>
<% else: %>
<table class="table table-striped table-hover">
<thead>
<tr>
<th><%- @T('Organization') %>
<th><%- @T('Time Units') %>
</thead>
<tbody>
<% for row in @rows: %>
<tr>
<td><a href="#organization/profile/<%- row.organization.id %>"><%= row.organization.name %></a>
<td><%= row.time_unit %>
<% end %>
</tbody>
</table>
</div>
<% end %>

View file

@ -0,0 +1,31 @@
<% if !@rows.length: %>
<table class="settings-list settings-list--stretch settings-list--placeholder">
<thead><tr><th><%- @T('No Entries') %>
</table>
<% else: %>
<table class="table table-striped table-hover">
<thead>
<tr>
<th><%- @T('Ticket#') %>
<th><%- @T('Title') %>
<th><%- @T('Customer') %>
<th><%- @T('Organization') %>
<th><%- @T('Agent') %>
<th><%- @T('Time Units') %>
<th><%- @T('Time Units Total') %>
</thead>
<tbody>
<% for row in @rows: %>
<tr>
<td><a href="#ticket/zoom/<%- row.ticket.id %>"><%= row.ticket.number %></a>
<td title="<%= row.ticket.title %>"><%= row.ticket.title %>
<td><%= row.customer %>
<td><%= row.organization %>
<td><%= row.agent %>
<td><%= row.time_unit %>
<td><%= row.ticket.time_unit %>
<% end %>
</tbody>
</table>
</div>
<% end %>

View file

@ -0,0 +1,46 @@
<div class="page-header">
<div class="page-header-title">
<div class="zammad-switch zammad-switch--small js-timeAccountingSetting">
<input name="chat" type="checkbox" id="time-accounting" <% if @C('time_accounting'): %>checked<% end %>>
<label for="time-accounting"></label>
</div>
<h1><%- @T('Time Accounting') %><small></small></h1>
</div>
</div>
<div class="page-content">
<div class="settings-entry">
<div class="page-header-title">
<h2><%- @T('Selector') %></h2>
</div>
<p><%- @T('Enable time accounting for following matching tickets.') %></p>
<div class="js-selector"></div>
</div>
<div class="settings-entry">
<h2><%- @T('Overviews') %></h2>
<div class="well">
<div class="btn-group btn-group--full" role="group" aria-label="">
<% for item in @timeRangeYear: %>
<div class="btn btn--textLarge js-timePickerYear<%- ' is-selected' if @year is item.value %>" data-id="<%= @timeRange %>" data-type="<%= item.value %>"><%= item.display %></div>
<% end %>
</div>
<div class="btn-group btn-group--full" role="group" aria-label="">
<% for item in @timeRangeMonth: %>
<div class="btn btn--textLarge js-timePickerMonth<%- ' is-selected' if @month is item.value %>" data-id="<%= @timeRange %>" data-type="<%= item.value %>"><%= item.display %></div>
<% end %>
</div>
</div>
<h3><%- @T('Ticket') %></h3>
<div class="js-tableTicket"></div>
<br>
<h3><%- @T('Customer') %></h3>
<div class="js-tableCustomer"></div>
<br>
<h3><%- @T('Organization') %></h3>
<div class="js-tableOrganization"></div>
</div>
</div>

View file

@ -398,6 +398,9 @@ class ApplicationController < ActionController::Base
params[:sender_id] = Ticket::Article::Sender.lookup(name: sender).id params[:sender_id] = Ticket::Article::Sender.lookup(name: sender).id
end end
# remember time accounting
time_unit = params[:time_unit]
clean_params = Ticket::Article.param_association_lookup(params) clean_params = Ticket::Article.param_association_lookup(params)
clean_params = Ticket::Article.param_cleanup(clean_params, true) clean_params = Ticket::Article.param_cleanup(clean_params, true)
@ -447,6 +450,15 @@ class ApplicationController < ActionController::Base
end end
article.save! article.save!
# account time
if time_unit.present?
Ticket::TimeAccounting.create!(
ticket_id: article.ticket_id,
ticket_article_id: article.id,
time_unit: time_unit
)
end
# remove attachments from upload cache # remove attachments from upload cache
return article if !form_id return article if !form_id

View file

@ -0,0 +1,152 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
class TimeAccountingsController < ApplicationController
before_action { authentication_check(permission: 'admin.time_accounting') }
def by_ticket
year = params[:year] || Time.zone.now.year
month = params[:month] || Time.zone.now.month
start_periode = Date.parse("#{year}-#{month}-01")
end_periode = start_periode.end_of_month
time_unit = {}
Ticket::TimeAccounting.where('created_at >= ? AND created_at <= ?', start_periode, end_periode).pluck(:ticket_id, :time_unit, :created_by_id).each { |record|
if !time_unit[record[0]]
time_unit[record[0]] = {
time_unit: 0,
agent_id: record[2],
}
end
time_unit[record[0]][:time_unit] += record[1]
}
customers = {}
organizations = {}
agents = {}
results = []
time_unit.each { |ticket_id, local_time_unit|
ticket = Ticket.lookup(id: ticket_id)
next if !ticket
if !customers[ticket.customer_id]
customers[ticket.customer_id] = '-'
if ticket.customer_id
customer_user = User.lookup(id: ticket.customer_id)
if customer_user
customers[ticket.customer_id] = customer_user.fullname
end
end
end
if !organizations[ticket.organization_id]
organizations[ticket.organization_id] = '-'
if ticket.organization_id
organization = Organization.lookup(id: ticket.organization_id)
if organization
organizations[ticket.organization_id] = organization.name
end
end
end
if !customers[local_time_unit[:agent_id]]
agent_user = User.lookup(id: local_time_unit[:agent_id])
agent = '-'
if agent_user
agents[local_time_unit[:agent_id]] = agent_user.fullname
end
end
result = {
ticket: ticket.attributes,
time_unit: local_time_unit[:time_unit],
customer: customers[ticket.customer_id],
organization: organizations[ticket.organization_id],
agent: agents[local_time_unit[:agent_id]],
}
results.push result
}
render json: results
end
def by_customer
year = params[:year] || Time.zone.now.year
month = params[:month] || Time.zone.now.month
start_periode = Date.parse("#{year}-#{month}-01")
end_periode = start_periode.end_of_month
time_unit = {}
Ticket::TimeAccounting.where('created_at >= ? AND created_at <= ?', start_periode, end_periode).pluck(:ticket_id, :time_unit, :created_by_id).each { |record|
if !time_unit[record[0]]
time_unit[record[0]] = {
time_unit: 0,
agent_id: record[2],
}
end
time_unit[record[0]][:time_unit] += record[1]
}
customers = {}
time_unit.each { |ticket_id, local_time_unit|
ticket = Ticket.lookup(id: ticket_id)
next if !ticket
if !customers[ticket.customer_id]
organization = nil
if ticket.organization_id
organization = Organization.lookup(id: ticket.organization_id).attributes
end
customers[ticket.customer_id] = {
customer: User.lookup(id: ticket.customer_id).attributes,
organization: organization,
time_unit: local_time_unit[:time_unit],
}
next
end
customers[ticket.customer_id][:time_unit] += local_time_unit[:time_unit]
}
results = []
customers.each { |_customer_id, content|
results.push content
}
render json: results
end
def by_organization
year = params[:year] || Time.zone.now.year
month = params[:month] || Time.zone.now.month
start_periode = Date.parse("#{year}-#{month}-01")
end_periode = start_periode.end_of_month
time_unit = {}
Ticket::TimeAccounting.where('created_at >= ? AND created_at <= ?', start_periode, end_periode).pluck(:ticket_id, :time_unit, :created_by_id).each { |record|
if !time_unit[record[0]]
time_unit[record[0]] = {
time_unit: 0,
agent_id: record[2],
}
end
time_unit[record[0]][:time_unit] += record[1]
}
organizations = {}
time_unit.each { |ticket_id, local_time_unit|
ticket = Ticket.lookup(id: ticket_id)
next if !ticket
next if !ticket.organization_id
if !organizations[ticket.organization_id]
organizations[ticket.organization_id] = {
organization: Organization.lookup(id: ticket.organization_id).attributes,
time_unit: local_time_unit[:time_unit],
}
next
end
organizations[ticket.organization_id][:time_unit] += local_time_unit[:time_unit]
}
results = []
organizations.each { |_customer_id, content|
results.push content
}
render json: results
end
end

View file

@ -0,0 +1,23 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
class Ticket::TimeAccounting < ApplicationModel
after_create :ticket_time_unit_update
after_update :ticket_time_unit_update
def ticket_time_unit_update
exists = false
time_units = 0
Ticket::TimeAccounting.where(ticket_id: ticket_id).each { |record|
time_units += record.time_unit
exists = true
}
return false if exists == false
ticket = Ticket.lookup(id: ticket_id)
return false if !ticket
return false if ticket.time_unit == time_units
ticket.time_unit = time_units
ticket.save!
true
end
end

View file

@ -0,0 +1,8 @@
Zammad::Application.routes.draw do
api_path = Rails.configuration.api_path
match api_path + '/time_accounting/log/by_ticket/:year/:month', to: 'time_accountings#by_ticket', via: :get
match api_path + '/time_accounting/log/by_customer/:year/:month', to: 'time_accountings#by_customer', via: :get
match api_path + '/time_accounting/log/by_organization/:year/:month', to: 'time_accountings#by_organization', via: :get
end

View file

@ -31,6 +31,7 @@ class CreateTicket < ActiveRecord::Migration
t.timestamps limit: 3, null: false t.timestamps limit: 3, null: false
end end
add_index :ticket_priorities, [:name], unique: true add_index :ticket_priorities, [:name], unique: true
create_table :tickets do |t| create_table :tickets do |t|
t.references :group, null: false t.references :group, null: false
t.references :priority, null: false t.references :priority, null: false
@ -61,6 +62,7 @@ class CreateTicket < ActiveRecord::Migration
t.column :escalation_at, :timestamp, limit: 3, null: true t.column :escalation_at, :timestamp, limit: 3, null: true
t.column :pending_time, :timestamp, limit: 3, null: true t.column :pending_time, :timestamp, limit: 3, null: true
t.column :type, :string, limit: 100, null: true t.column :type, :string, limit: 100, null: true
t.column :time_unit, :decimal, precision: 6, scale: 2, null: false
t.column :preferences, :text, limit: 500.kilobytes + 1, null: true t.column :preferences, :text, limit: 500.kilobytes + 1, null: true
t.column :updated_by_id, :integer, null: false t.column :updated_by_id, :integer, null: false
t.column :created_by_id, :integer, null: false t.column :created_by_id, :integer, null: false
@ -93,6 +95,7 @@ class CreateTicket < ActiveRecord::Migration
add_index :tickets, [:created_by_id] add_index :tickets, [:created_by_id]
add_index :tickets, [:pending_time] add_index :tickets, [:pending_time]
add_index :tickets, [:type] add_index :tickets, [:type]
add_index :tickets, [:time_unit]
create_table :ticket_flags do |t| create_table :ticket_flags do |t|
t.references :tickets, null: false t.references :tickets, null: false
@ -106,16 +109,17 @@ class CreateTicket < ActiveRecord::Migration
add_index :ticket_flags, [:tickets_id] add_index :ticket_flags, [:tickets_id]
add_index :ticket_flags, [:created_by_id] add_index :ticket_flags, [:created_by_id]
create_table :ticket_time_accounting do |t| create_table :ticket_time_accountings do |t|
t.references :tickets, null: false t.references :ticket, null: false
t.references :ticket_articles, null: true t.references :ticket_article, null: true
t.column :time_unit, :decimal, precision: 6, scale: 2, null: false t.column :time_unit, :decimal, precision: 6, scale: 2, null: false
t.column :created_by_id, :integer, null: false t.column :created_by_id, :integer, null: false
t.timestamps limit: 3, null: false t.timestamps limit: 3, null: false
end end
add_index :ticket_time_accounting, [:tickets_id] add_index :ticket_time_accountings, [:ticket_id]
add_index :ticket_time_accounting, [:ticket_articles_id] add_index :ticket_time_accountings, [:ticket_article_id]
add_index :ticket_time_accounting, [:created_by_id] add_index :ticket_time_accountings, [:created_by_id]
add_index :ticket_time_accountings, [:time_unit]
create_table :ticket_article_types do |t| create_table :ticket_article_types do |t|
t.column :name, :string, limit: 250, null: false t.column :name, :string, limit: 250, null: false

View file

@ -0,0 +1,25 @@
class AddTicketTimeAccounting373 < ActiveRecord::Migration
def up
# return if it's a new setup
return if !Setting.find_by(name: 'system_init_done')
drop_table :ticket_time_accountings
create_table :ticket_time_accountings do |t|
t.references :ticket, null: false
t.references :ticket_article, null: true
t.column :time_unit, :decimal, precision: 6, scale: 2, null: false
t.column :created_by_id, :integer, null: false
t.timestamps limit: 3, null: false
end
add_index :ticket_time_accountings, [:ticket_id]
add_index :ticket_time_accountings, [:ticket_article_id]
add_index :ticket_time_accountings, [:created_by_id]
add_index :ticket_time_accountings, [:time_unit]
add_column :tickets, :time_unit, :decimal, precision: 6, scale: 2, null: true
add_index :tickets, [:time_unit]
Cache.clear
end
end

View file

@ -2034,6 +2034,51 @@ Setting.create_if_not_exists(
frontend: false frontend: false
) )
Setting.create_if_not_exists(
title: 'Time Accounting',
name: 'time_accounting',
area: 'Web::Base',
description: 'Enable time accounting.',
options: {
form: [
{
display: '',
null: true,
name: 'time_accounting',
tag: 'boolean',
options: {
true => 'yes',
false => 'no',
},
},
],
},
preferences: {
authentication: true,
permission: ['admin.time_accounting'],
},
state: false,
frontend: true
)
Setting.create_if_not_exists(
title: 'Time Accounting Selector',
name: 'time_accounting_selector',
area: 'Web::Base',
description: 'Enable time accounting for this tickets.',
options: {
form: [
{},
],
},
preferences: {
authentication: true,
permission: ['admin.time_accounting'],
},
state: {},
frontend: true
)
Setting.create_if_not_exists( Setting.create_if_not_exists(
title: 'New Tags', title: 'New Tags',
name: 'tag_new', name: 'tag_new',