copy to trunk

git-svn-id: https://dev.finn.de/svn/cockpit/trunk@936 7926756e-e54e-46e6-9721-ed318f58905e
pull/6827/head
rkh 15 years ago
commit 824742112f
  1. 3
      README.rdoc
  2. 239
      app/controllers/cost_objects_controller.rb
  3. 307
      app/controllers/cost_reports_controller.rb
  4. 95
      app/controllers/cost_types_controller.rb
  5. 206
      app/controllers/costlog_controller.rb
  6. 110
      app/controllers/hourly_rates_controller.rb
  7. 53
      app/helpers/cost_objects_helper.rb
  8. 34
      app/helpers/cost_reports_helper.rb
  9. 3
      app/helpers/cost_types_helper.rb
  10. 98
      app/helpers/costlog_helper.rb
  11. 3
      app/helpers/hourly_rates_helper.rb
  12. 121
      app/models/cost_entry.rb
  13. 145
      app/models/cost_object.rb
  14. 40
      app/models/cost_query.rb
  15. 199
      app/models/cost_query/chainable.rb
  16. 13
      app/models/cost_query/filter.rb
  17. 5
      app/models/cost_query/filter/activity_id.rb
  18. 8
      app/models/cost_query/filter/assigned_to_id.rb
  19. 124
      app/models/cost_query/filter/base.rb
  20. 8
      app/models/cost_query/filter/category_id.rb
  21. 7
      app/models/cost_query/filter/cost_type_id.rb
  22. 3
      app/models/cost_query/filter/created_on.rb
  23. 4
      app/models/cost_query/filter/due_date.rb
  24. 4
      app/models/cost_query/filter/fixed_version_id.rb
  25. 5
      app/models/cost_query/filter/issue_id.rb
  26. 5
      app/models/cost_query/filter/no_filter.rb
  27. 3
      app/models/cost_query/filter/overridden_costs.rb
  28. 3
      app/models/cost_query/filter/priority_id.rb
  29. 5
      app/models/cost_query/filter/project_id.rb
  30. 3
      app/models/cost_query/filter/spent_on.rb
  31. 4
      app/models/cost_query/filter/start_date.rb
  32. 4
      app/models/cost_query/filter/status_id.rb
  33. 3
      app/models/cost_query/filter/subject.rb
  34. 3
      app/models/cost_query/filter/tracker_id.rb
  35. 3
      app/models/cost_query/filter/updated_on.rb
  36. 5
      app/models/cost_query/filter/user_id.rb
  37. 11
      app/models/cost_query/group_by.rb
  38. 4
      app/models/cost_query/group_by/activity_id.rb
  39. 37
      app/models/cost_query/group_by/base.rb
  40. 5
      app/models/cost_query/group_by/cost_object_id.rb
  41. 4
      app/models/cost_query/group_by/cost_type_id.rb
  42. 4
      app/models/cost_query/group_by/issue_id.rb
  43. 4
      app/models/cost_query/group_by/project_id.rb
  44. 15
      app/models/cost_query/group_by/ruby_aggregation.rb
  45. 4
      app/models/cost_query/group_by/spent_on.rb
  46. 15
      app/models/cost_query/group_by/sql_aggregation.rb
  47. 4
      app/models/cost_query/group_by/tmonth.rb
  48. 5
      app/models/cost_query/group_by/tracker_id.rb
  49. 4
      app/models/cost_query/group_by/tweek.rb
  50. 4
      app/models/cost_query/group_by/tyear.rb
  51. 4
      app/models/cost_query/group_by/user_id.rb
  52. 247
      app/models/cost_query/operator.rb
  53. 120
      app/models/cost_query/query_utils.rb
  54. 108
      app/models/cost_query/result.rb
  55. 257
      app/models/cost_query/sql_statement.rb
  56. 28
      app/models/cost_rate.rb
  57. 74
      app/models/cost_type.rb
  58. 33
      app/models/default_hourly_rate.rb
  59. 86
      app/models/default_hourly_rate_observer.rb
  60. 80
      app/models/entry.rb
  61. 6
      app/models/fixed_cost_object.rb
  62. 47
      app/models/hourly_rate.rb
  63. 23
      app/models/labor_budget_item.rb
  64. 19
      app/models/material_budget_item.rb
  65. 24
      app/models/rate.rb
  66. 177
      app/models/rate_observer.rb
  67. 96
      app/models/variable_cost_object.rb
  68. 1
      app/views/cost_objects/_costs.rhtml
  69. 31
      app/views/cost_objects/_edit.rhtml
  70. 95
      app/views/cost_objects/_form.rhtml
  71. 41
      app/views/cost_objects/_labor_budget_item.rhtml
  72. 53
      app/views/cost_objects/_list.rhtml
  73. 45
      app/views/cost_objects/_material_budget_item.rhtml
  74. 0
      app/views/cost_objects/_show_fixed_cost_object.rhtml
  75. 130
      app/views/cost_objects/_show_variable_cost_object.rhtml
  76. 4
      app/views/cost_objects/_sidebar.rhtml
  77. 4
      app/views/cost_objects/edit.rhtml
  78. 40
      app/views/cost_objects/index.rhtml
  79. 38
      app/views/cost_objects/new.rhtml
  80. 68
      app/views/cost_objects/show.rhtml
  81. 36
      app/views/cost_reports/_filter.rhtml
  82. 148
      app/views/cost_reports/_filters.rhtml
  83. 18
      app/views/cost_reports/_group_by.rhtml
  84. 5
      app/views/cost_reports/_list.rhtml
  85. 94
      app/views/cost_reports/_list_group_by.rhtml
  86. 61
      app/views/cost_reports/_list_items.rhtml
  87. 5
      app/views/cost_reports/_options.rhtml
  88. 0
      app/views/cost_reports/_sidebar.rhtml
  89. 0
      app/views/cost_reports/filter_types/_boolean.rhtml
  90. 1
      app/views/cost_reports/filter_types/_date.rhtml
  91. 13
      app/views/cost_reports/filter_types/_date_exact.rhtml
  92. 1
      app/views/cost_reports/filter_types/_date_past.rhtml
  93. 1
      app/views/cost_reports/filter_types/_integer.rhtml
  94. 1
      app/views/cost_reports/filter_types/_integer_zero.rhtml
  95. 2
      app/views/cost_reports/filter_types/_list.rhtml
  96. 1
      app/views/cost_reports/filter_types/_list_optional.rhtml
  97. 1
      app/views/cost_reports/filter_types/_list_status.rhtml
  98. 1
      app/views/cost_reports/filter_types/_list_subprojects.rhtml
  99. 1
      app/views/cost_reports/filter_types/_string.rhtml
  100. 1
      app/views/cost_reports/filter_types/_text.rhtml
  101. Some files were not shown because too many files have changed in this diff Show More

@ -0,0 +1,3 @@
= costs
Description goes here

@ -0,0 +1,239 @@
class CostObjectsController < ApplicationController
unloadable
before_filter :find_cost_object, :only => [:show, :edit]
before_filter :find_cost_objects, :only => [:bulk_edit, :destroy]
before_filter :find_project, :only => [
:preview, :new,
:update_material_budget_item, :update_labor_budget_item
]
before_filter :find_optional_project, :only => [:index]
# :new action is authorized in the action itself
# all other actions listed here are unrestricted
before_filter :authorize, :except => [
# unrestricted actions
:index, :preview, :context_menu,
:update_material_budget_item, :update_labor_budget_item
]
verify :method => :post, :only => [:bulk_edit, :destroy],
:redirect_to => { :action => :list }
helper :sort
include SortHelper
helper :projects
include ProjectsHelper
helper :attachments
include AttachmentsHelper
helper :costlog
include CostlogHelper
helper :cost_objects
include CostObjectsHelper
include Redmine::Export::PDF
def index
limit = per_page_option
respond_to do |format|
format.html { }
format.csv { limit = Setting.issues_export_limit.to_i }
format.pdf { limit = Setting.issues_export_limit.to_i }
end
sort_columns = {'id' => "#{CostObject.table_name}.id",
'subject' => "#{CostObject.table_name}.subject",
'fixed_date' => "#{CostObject.table_name}.fixed_date"
}
sort_init "id", "desc"
sort_update sort_columns
conditions = @project ? {:project_id => @project} : {}
@cost_object_count = CostObject.count(:include => [:project], :conditions => conditions)
@cost_object_pages = Paginator.new self, @cost_object_count, limit, params[:page]
@cost_objects = CostObject.find :all, :order => sort_clause,
:include => [:project],
:conditions => conditions,
:limit => limit,
:offset => @cost_object_pages.current.offset
respond_to do |format|
format.html { render :action => 'index', :layout => !request.xhr? }
format.csv { send_data(cost_objects_to_csv(@cost_objects, @project).read, :type => 'text/csv; header=present', :filename => 'export.csv') }
format.pdf { send_data(cost_objects_to_pdf(@cost_objects, @project), :type => 'application/pdf', :filename => 'export.pdf') }
end
end
def show
@edit_allowed = User.current.allowed_to?(:edit_cost_objects, @project)
respond_to do |format|
format.html { render :action => 'show', :layout => !request.xhr? }
end
end
def new
if params[:cost_object]
@cost_object = create_cost_object(params[:cost_object].delete(:kind))
elsif params[:copy_from]
source = CostObject.find(params[:copy_from])
if source
@cost_object = create_cost_object(source.kind)
@cost_object.copy_from(params[:copy_from])
end
end
# FIXME: I forcibly create a VariableCostObject for now. Following Ticket #5360
@cost_object ||= VariableCostObject.new
@cost_object.project_id = @project.id
# fixed_date must be set before material_budget_items and labor_budget_items
if params[:cost_object] && params[:cost_object][:fixed_date]
@cost_object.fixed_date = params[:cost_object].delete(:fixed_date)
else
@cost_object.fixed_date = Date.today
end
@cost_object.attributes = params[:cost_object]
unless request.get? || request.xhr?
if @cost_object.save
flash[:notice] = l(:notice_successful_create)
redirect_to(params[:continue] ? { :action => 'new' } :
{ :action => 'show', :id => @cost_object })
return
end
end
render :layout => !request.xhr?
end
# Blacklist of attributes which can not be updated in edit
EDIT_BLACK_LIST = %w(kind type)
def edit
if params[:cost_object]
attrs = params[:cost_object].dup
attrs.delete_if {|k,v| EDIT_BLACK_LIST.include?(k)}
@cost_object.attributes = attrs
end
if request.post?
if @cost_object.save
flash[:notice] = l(:notice_successful_update)
redirect_to(params[:back_to] || {:action => 'show', :id => @cost_object})
end
end
rescue ActiveRecord::StaleObjectError
# Optimistic locking exception
flash.now[:error] = l(:notice_locking_conflict)
end
def destroy
@cost_objects.each(&:destroy)
flash[:notice] = l(:notice_successful_delete)
redirect_to :action => 'index', :project_id => @project
end
def preview
@cost_object = CostObjects.find_by_id(params[:id]) unless params[:id].blank?
@text = params[:notes] || (params[:cost_object] ? params[:cost_object][:description] : nil)
render :partial => 'common/preview'
end
def update_material_budget_item
element_id = params[:element_id] if params.has_key? :element_id
cost_type = CostType.find(params[:cost_type_id]) if params.has_key? :cost_type_id
units = params[:units].strip.gsub(',', '.').to_f
costs = (units * cost_type.rate_at(params[:fixed_date]).rate rescue 0.0)
if request.xhr?
render :update do |page|
if User.current.allowed_to? :view_unit_price, @project
page.replace_html "#{element_id}_costs", number_to_currency(costs)
end
page.replace_html "#{element_id}_unit_name", h(units == 1.0 ? cost_type.unit : cost_type.unit_plural)
end
end
rescue ActiveRecord::RecordNotFound
render_404
end
def update_labor_budget_item
element_id = params[:element_id] if params.has_key? :element_id
user = User.find(params[:user_id])
hours = params[:hours].to_hours
costs = hours * user.rate_at(params[:fixed_date], @project).rate rescue 0.0
if request.xhr?
render :update do |page|
if User.current.allowed_to?(:view_all_rates, @project) || (user == User.current && User.current.allowed_to?(:view_own_rate, @project))
page.replace_html "#{element_id}_costs", number_to_currency(costs)
end
end
end
rescue ActiveRecord::RecordNotFound
render :update do |page|
page.replace_html "#{element_id}_costs", number_to_currency(0.0)
end
end
private
def create_cost_object(kind)
case kind
when FixedCostObject.name
FixedCostObject.new
when VariableCostObject.name
VariableCostObject.new
else
CostObject.new
end
end
def find_cost_object
# This function comes directly from issues_controller.rb (Redmine 0.8.4)
@cost_object = CostObject.find(params[:id], :include => [:project, :author])
@project = @cost_object.project
rescue ActiveRecord::RecordNotFound
render_404
end
def find_cost_objects
# This function comes directly from issues_controller.rb (Redmine 0.8.4)
@cost_objects = CostObject.find_all_by_id(params[:id] || params[:ids])
raise ActiveRecord::RecordNotFound if @cost_objects.empty?
projects = @cost_objects.collect(&:project).compact.uniq
if projects.size == 1
@project = projects.first
else
# TODO: let users bulk edit/move/destroy cost_objects from different projects
render_error 'Can not bulk edit/move/destroy cost objects from different projects' and return false
end
rescue ActiveRecord::RecordNotFound
render_404
end
def find_project
@project = Project.find(params[:project_id])
rescue ActiveRecord::RecordNotFound
render_404
end
def find_optional_project
@project = Project.find(params[:project_id]) unless params[:project_id].blank?
allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
allowed ? true : deny_access
rescue ActiveRecord::RecordNotFound
render_404
end
end

@ -0,0 +1,307 @@
class CostReportsController < ApplicationController
unloadable
before_filter :find_optional_project, :only => [:index]
before_filter :retrieve_query
before_filter :authorize
helper :sort
include SortHelper
def index
sort_init(@query.sort_criteria.empty? ? [['issue_id', 'desc']] : @query.sort_criteria)
sortable_columns = {
"issue__issue_id" => "issue_id",
"entry__spent_on" => "spent_on",
"entry__user_id" => "user_id",
"entry__cost_type_id" => "cost_type_id",
"entry__activity_id" => "activity_id",
"entry__costs" => "real_costs"
}
sort_update(sortable_columns)
if @query.valid?
limit = per_page_option
respond_to do |format|
format.html { }
format.atom { }
format.csv { limit = Setting.issues_export_limit.to_i }
format.pdf { limit = Setting.issues_export_limit.to_i }
end
unless @query.group_by_fields.empty?
get_aggregation
respond_to do |format|
format.html { render :layout => !request.xhr? }
# TODO: ATOM and CSV
end
else
get_entries(limit)
respond_to do |format|
format.html { render :layout => !request.xhr? }
format.atom { render_feed(@issues, :title => "#{@project || Setting.app_title}: #{l(:label_issue_plural)}") }
format.csv { send_data(entries_to_csv(@entries, @project).read, :type => 'text/csv; header=present', :filename => 'export.csv') }
end
end
else
render :layout => !request.xhr?
end
rescue ActiveRecord::RecordNotFound
render_404
end
def new
# This action saves a new query for later reference
end
private
def find_optional_project
@project = Project.find(params[:project_id]) unless params[:project_id].blank?
allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
allowed ? true : deny_access
rescue ActiveRecord::RecordNotFound
render_404
end
def retrieve_query
# tries to find a active query in the session or loads the default one
unless params[:query_id].blank?
# The user provided an explicit query_id
cond = "project_id IS NULL"
cond << " OR project_id = #{@project.id}" if @project
@query = CostQuery.find(params[:query_id], :conditions => cond)
@query.project = @project
session[:cost_query] = {:id => @query.id, :project_id => @query.project_id}
sort_clear
else
if params[:set_filter] || session[:cost_query].nil? || session[:cost_query][:project_id] != (@project ? @project.id : nil)
# We have no current query or the query was reseted explicitly
# So generate a new query
# Give it a name, required to be valid
@query = CostQuery.new(:name => "_")
@query.project = @project
if params[:filters].blank?
@query.filters = []
else
@query.filters = params[:filters].collect {|f| f[1]}.select{|f| f[:enabled] != "0"}
end
@query.group_by = params[:group_by] || {}
if params[:cost_query]
@query.display_cost_entries = params[:cost_query][:display_cost_entries]
@query.display_time_entries = params[:cost_query][:display_time_entries]
end
session[:cost_query] = {:project_id => @query.project_id,
:filters => @query.filters,
:group_by => @query.group_by,
:display_cost_entries => @query.display_cost_entries,
:display_time_entries => @query.display_time_entries}
else
@query = CostQuery.find_by_id(session[:cost_query][:id]) if session[:cost_query][:id]
@query ||= CostQuery.new(:name => "_",
:project => @project,
:filters => session[:cost_query][:filters],
:group_by => session[:cost_query][:group_by],
:display_cost_entries => session[:cost_query][:display_cost_entries],
:display_time_entries => session[:cost_query][:display_time_entries])
@query.project = @project
end
end
end
def get_aggregation
fields = @query.group_by_fields.join(", ")
scopes = []
scopes << :cost_entries if @query.display_cost_entries
scopes << :time_entries if @query.display_time_entries
return @grouped_entries = [] if scopes.blank?
subselect = scopes.map do |type|
model, select_statement, from, where_statement, group_by_statement = @query.sql_data_for type
table = model.table_name
<<-EOS
SELECT
#{select_statement},
SUM(
CASE WHEN #{table}.overridden_costs IS NULL THEN #{table}.costs
ELSE #{table}.overridden_costs END) AS sum,
COUNT(*) AS count
FROM #{from}
WHERE #{where_statement}
#{group_by_statement}
EOS
end.join(" UNION ")
if scopes.length == 2
sql = "SELECT #{fields}, SUM(sum) as sum, SUM(count) AS count FROM (#{subselect}) AS entries GROUP BY #{fields}"
else
sql = subselect
end
@grouped_entries = ActiveRecord::Base.connection.select_all(sql)
@entry_count, @entry_sum = @grouped_entries.inject([0, 0.0]) do |r,i|
r[0] += i["count"].to_i
r[1] +=i ["sum"].to_f
r
end
end
def get_entries(limit)
cost_where = @query.statement(:cost_entries)
time_where = @query.statement(:time_entries)
aggregate_select = [TimeEntry.table_name, CostEntry.table_name].inject({}) do |r,table|
r[table] = <<-EOS
COUNT(#{table}.id) as count,
SUM(CASE
WHEN #{table}.overridden_costs IS NULL
THEN #{table}.costs
ELSE #{table}.overridden_costs
END
) as sum
EOS
r
end
# at first get the entry ids to match the current query
unless sort_clause.nil?
(sort_column, sort_order) = sort_clause.split(" ")
sort_column.gsub!(/\./, "__")
case sort_column
when "real_costs"
cost_sort_column = sort_column
cost_sort_column_sql = "costs, overridden_costs,"
time_sort_column = sort_column
time_sort_column_sql = "costs, overridden_costs,"
sort_clause = "overridden_costs #{sort_order}, costs #{sort_order}"
else
cost_sort_column = (CostEntry.new.respond_to? sort_column) ? sort_column : nil
cost_sort_column_sql = cost_sort_column || "NULL as #{sort_column}"
cost_sort_column_sql += ","
time_sort_column = (TimeEntry.new.respond_to? sort_column) ? sort_column : nil
time_sort_column_sql = time_sort_column || "NULL as #{sort_column}"
time_sort_column_sql += ","
sort_clause = self.sort_clause
end
end
if @query.display_time_entries
time_entry_sum, time_entry_count = TimeEntry.all(
:select => aggregate_select[TimeEntry.table_name],
:conditions => time_where,
:from => @query.from_statement(:time_entries)
).map {|i| [i.sum.to_f, i.count.to_i] }[0]
end
if @query.display_cost_entries
cost_entry_sum, cost_entry_count = CostEntry.all(
:select => aggregate_select[CostEntry.table_name],
:conditions => cost_where,
:from => @query.from_statement(:cost_entries)
).map {|i| [i.sum.to_f, i.count.to_i] }[0]
end
if @query.display_time_entries && !@query.display_cost_entries
@entry_sum, @entry_count = [time_entry_sum, time_entry_count]
@entry_pages = Paginator.new self, @entry_count, limit, params['page']
@entries = TimeEntry.find :all, {:order => (sort_clause if time_sort_column),
:include => [:issue, :activity, :user],
:conditions => time_where,
:limit => limit,
:offset => @entry_pages.current.offset}
return
elsif @query.display_cost_entries && !@query.display_time_entries
@entry_sum, @entry_count = [cost_entry_sum, cost_entry_count]
@entry_pages = Paginator.new self, @entry_count, limit, params['page']
@entries = CostEntry.find :all, {:order => (sort_clause if cost_sort_column),
:include => [:issue, :cost_type, :user],
:conditions => cost_where,
:limit => limit,
:offset => @entry_pages.current.offset}
return
elsif !@query.display_time_entries && !@query.display_time_entries
@entry_sum, @entry_count = [0 , 0]
@entry_pages = Paginator.new self, @entry_count, limit, params['page']
@entries = []
return
end
@entry_count = time_entry_count + cost_entry_count
@entry_sum = time_entry_sum + cost_entry_sum
@entry_pages = Paginator.new self, @entry_count, limit, params['page']
cost_from = @query.from_statement(:cost_entries)
time_from = @query.from_statement(:time_entries)
# TAKE extra care for SQL injection here!!!
sql = " SELECT #{CostEntry.table_name}.id AS id, #{cost_sort_column_sql} 'cost_entry' AS entry_type"
sql << " FROM #{cost_from}"
sql << " WHERE #{cost_where}"
sql << " UNION"
sql << " SELECT #{TimeEntry.table_name}.id AS id, #{time_sort_column_sql} 'time_entry' as entry_type"
sql << " FROM #{time_from}"
sql << " WHERE #{time_where}"
sql << " ORDER BY #{sort_clause}" if sort_clause
sql << " LIMIT #{limit} OFFSET #{@entry_pages.current.offset}"
raw_ids = ActiveRecord::Base.connection.select_all(sql)
cost_entry_ids = []
time_entry_ids = []
raw_ids.each do |row|
case row["entry_type"]
when "cost_entry"
cost_entry_ids << row["id"]
when "time_entry"
time_entry_ids << row["id"]
else
raise "Unknown entry type in SQL. Should never happen."
end
end
cost_entries = CostEntry.find :all, {:order => (sort_clause if cost_sort_column),
:include => [:issue, :cost_type, :user],
:conditions => {:id => cost_entry_ids}}
time_entries = TimeEntry.find :all, {:order => (sort_clause if time_sort_column),
:include => [:issue, :activity, :user],
:conditions => {:id => time_entry_ids}}
# now we merge the both entry types
if cost_sort_column && time_sort_column
@entries = cost_entries + time_entries
@entries.sort!{|a,b| a.send(sort_column) <=> b.send(sort_column)}
@entries.reverse! if sort_order && sort_order == "DESC"
elsif cost_sort_column
@entries = cost_entries + time_entries
else
@entries = time_entries + cost_entries
end
end
end

@ -0,0 +1,95 @@
class CostTypesController < ApplicationController
unloadable
# Allow only admins here
before_filter :require_admin
before_filter :find_cost_type, :only => [:set_rate, :toggle_delete]
before_filter :find_optional_cost_type, :only => [:edit]
verify :method => :post, :only => [:set_rate, :toggle_delete], :redirect_to => { :action => :index }
helper :sort
include SortHelper
helper :cost_types
include CostTypesHelper
def index
sort_init 'name', 'asc'
sort_columns = { "name" => "#{CostType.table_name}.name",
"unit" => "#{CostType.table_name}.unit",
"unit_plural" => "#{CostType.table_name}.unit_plural" }
sort_update sort_columns
@cost_types = CostType.find :all, :order => @sort_clause
unless params[:clear_filter]
@fixed_date = Date.parse(params[:fixed_date]) rescue Date.today
@include_deleted = params[:include_deleted]
else
@fixed_date = Date.today
@include_deleted = nil
end
render :action => 'index', :layout => !request.xhr?
end
def edit
if !@cost_type
@cost_type = CostType.new()
end
if params[:cost_type]
@cost_type.attributes = params[:cost_type]
end
if request.post? && @cost_type.save
flash[:notice] = l(:notice_successful_update)
redirect_back_or_default(:action => 'index')
else
@cost_type.rates.build({:valid_from => Date.today}) if @cost_type.rates.empty?
render :action => "edit", :layout => !request.xhr?
end
rescue ActiveRecord::StaleObjectError
# Optimistic locking exception
flash.now[:error] = l(:notice_locking_conflict)
end
def toggle_delete
@cost_type.deleted_at = @cost_type.deleted_at ? nil : DateTime.now()
@cost_type.default = false
if request.post? && @cost_type.save
flash[:notice] = @cost_type.deleted_at ? l(:notice_successful_delete) : l(:notice_successful_restore)
redirect_back_or_default(:action => 'index')
end
end
def set_rate
today = Date.today
rate = @cost_type.rate_at(today)
rate ||= CostRate.new(:cost_type => @cost_type, :valid_from => today)
rate.rate = clean_currency(params[:rate]).to_f
if rate.save
flash[:notice] = l(:notice_successful_update)
redirect_to :action => 'index'
else
# FIXME: Do some real error handling here
flash[:error] = l(:notice_something_wrong)
redirect_to :action => 'index'
end
end
private
def find_cost_type
@cost_type = CostType.find(params[:id])
rescue ActiveRecord::RecordNotFound
render_404
end
def find_optional_cost_type
if !params[:id].blank?
@cost_type = CostType.find(params[:id])
end
end
end

@ -0,0 +1,206 @@
class CostlogController < ApplicationController
unloadable
menu_item :issues
before_filter :find_project, :authorize, :only => [:edit, :destroy]
before_filter :find_optional_project, :only => [:report, :details]
verify :method => :post, :only => :destroy, :redirect_to => { :action => :details }
helper :sort
include SortHelper
helper :issues
include CostlogHelper
def details
sort_init 'spent_on', 'desc'
sort_update 'spent_on' => 'spent_on',
'user' => 'user_id',
'project' => "#{Project.table_name}.name",
'issue' => 'issue_id',
'cost_type' => 'cost_type_id',
'units' => 'units',
'costs' => 'costs'
cond = ARCondition.new
if @project.nil?
cond << Project.allowed_to_condition(User.current, :view_cost_entries)
elsif @issue.nil?
cond << @project.project_condition(Setting.display_subprojects_issues?)
else
cond << ["#{CostEntry.table_name}.issue_id = ?", @issue.id]
end
if @cost_type
cond << ["#{CostEntry.table_name}.cost_type_id = ?", @cost_type.id ]
end
retrieve_date_range
cond << ['spent_on BETWEEN ? AND ?', @from, @to]
CostEntry.visible_by(User.current) do
respond_to do |format|
format.html {
# Paginate results
@entry_count = CostEntry.count(:include => :project, :conditions => cond.conditions)
@entry_pages = Paginator.new self, @entry_count, per_page_option, params['page']
@entries = CostEntry.find(:all,
:include => [:project, :cost_type, :user, {:issue => :tracker}],
:conditions => cond.conditions,
:order => sort_clause,
:limit => @entry_pages.items_per_page,
:offset => @entry_pages.current.offset)
render :layout => !request.xhr?
}
format.atom {
entries = TimeEntry.find(:all,
:include => [:project, :cost_type, :user, {:issue => :tracker}],
:conditions => cond.conditions,
:order => "#{CostEntry.table_name}.created_on DESC",
:limit => Setting.feeds_limit.to_i)
render_feed(entries, :title => l(:label_spent_costs))
}
format.csv {
# Export all entries
@entries = CostEntry.find(:all,
:include => [:project, :cost_type, :user, {:issue => [:tracker, :assigned_to, :priority]}],
:conditions => cond.conditions,
:order => sort_clause)
send_data(entries_to_csv(@entries).read, :type => 'text/csv; header=present', :filename => 'costlog.csv')
}
end
end
end
def edit
render_403 and return if @cost_entry && !@cost_entry.editable_by?(User.current)
if !@cost_entry
# creates new CostEntry
if params[:cost_entry].is_a?(Hash)
# we have a new CostEntry in our request
new_user = User.find_by_id(params[:cost_entry][:user_id]) rescue nil
new_user ||= User.current
render_403 and return unless (
new_user == User.current && new_user.allowed_to?(:book_own_costs, @project) ||
new_user.allowed_to?(:book_costs, @project)
)
end
new_user ||= User.current
@cost_entry = CostEntry.new(:project => @project, :issue => @issue, :user => new_user, :spent_on => Date.today)
end
if params[:cost_entry].is_a?(Hash)
params[:cost_entry][:overridden_costs] = CostRate.clean_currency(params[:cost_entry][:overridden_costs])
end
@cost_entry.attributes = params[:cost_entry]
@cost_entry.cost_type ||= CostType.default
if request.post? and @cost_entry.save
flash[:notice] = l(:notice_successful_update)
redirect_back_or_default :action => 'details', :project_id => @cost_entry.project
return
end
end
def destroy
render_404 and return unless @cost_entry
render_403 and return unless @cost_entry.editable_by?(User.current)
@cost_entry.destroy
flash[:notice] = l(:notice_successful_delete)
redirect_to :back
rescue ::ActionController::RedirectBackError
redirect_to :action => 'details', :project_id => @cost_entry.project
end
def get_cost_type_unit_plural
@cost_type = CostType.find(params[:cost_type_id]) unless params[:cost_type_id].empty?
if request.xhr?
render :partial => "cost_type_unit_plural", :locals => {:cost_type => @cost_type}, :layout => false
end
end
private
def find_project
# copied from timelog_controller.rb
if params[:id]
@cost_entry = CostEntry.find(params[:id])
@project = @cost_entry.project
elsif params[:issue_id]
@issue = Issue.find(params[:issue_id])
@project = @issue.project
elsif params[:project_id]
@project = Project.find(params[:project_id])
else
render_404
return false
end
rescue ActiveRecord::RecordNotFound
render_404
end
def find_optional_project
if !params[:issue_id].blank?
@issue = Issue.find(params[:issue_id])
@project = @issue.project
elsif !params[:project_id].blank?
@project = Project.find(params[:project_id])
end
if !params[:cost_type_id].blank?
@cost_type = CostType.find(params[:cost_type_id])
end
deny_access unless User.current.allowed_to?(:view_cost_entries, @project, :global => true)
end
def retrieve_date_range
# Mostly copied from timelog_controller.rb
@free_period = false
@from, @to = nil, nil
if params[:period_type] == '1' || (params[:period_type].nil? && !params[:period].nil?)
case params[:period].to_s
when 'today'
@from = @to = Date.today
when 'yesterday'
@from = @to = Date.today - 1
when 'current_week'
@from = Date.today - (Date.today.cwday - 1)%7
@to = @from + 6
when 'last_week'
@from = Date.today - 7 - (Date.today.cwday - 1)%7
@to = @from + 6
when '7_days'
@from = Date.today - 7
@to = Date.today
when 'current_month'
@from = Date.civil(Date.today.year, Date.today.month, 1)
@to = (@from >> 1) - 1
when 'last_month'
@from = Date.civil(Date.today.year, Date.today.month, 1) << 1
@to = (@from >> 1) - 1
when '30_days'
@from = Date.today - 30
@to = Date.today
when 'current_year'
@from = Date.civil(Date.today.year, 1, 1)
@to = Date.civil(Date.today.year, 12, 31)
end
elsif params[:period_type] == '2' || (params[:period_type].nil? && (!params[:from].nil? || !params[:to].nil?))
begin; @from = params[:from].to_s.to_date unless params[:from].blank?; rescue; end
begin; @to = params[:to].to_s.to_date unless params[:to].blank?; rescue; end
@free_period = true
else
# default
end
@from, @to = @to, @from if @from && @to && @from > @to
@from ||= (CostEntry.minimum(:spent_on, :include => :project, :conditions => Project.allowed_to_condition(User.current, :view_cost_entries)) || Date.today) - 1
@to ||= (CostEntry.maximum(:spent_on, :include => :project, :conditions => Project.allowed_to_condition(User.current, :view_cost_entries)) || Date.today)
end
end

@ -0,0 +1,110 @@
class HourlyRatesController < ApplicationController
unloadable
helper :users
helper :sort
include SortHelper
helper :hourly_rates
include HourlyRatesHelper
before_filter :find_user, :only => [:show, :edit, :set_rate]
before_filter :find_optional_project, :only => [:show, :edit]
before_filter :find_project, :only => [:set_rate]
# #show and #edit have their own authorization
before_filter :authorize, :except => [:show, :edit]
def show
if @project
return deny_access unless user_allowed_to?(:view_hourly_rates, {:for_user => @user})
@rates = HourlyRate.find(:all,
:conditions => { :user_id => @user, :project_id => @project },
:order => "#{HourlyRate.table_name}.valid_from desc")
else
@rates = HourlyRate.history_for_user(@user, true)
@rates_default = @rates.delete(nil)
end
end
def edit
if @project
return deny_access unless user_allowed_to?(:change_hourly_rates, {:for_user => @user})
else
return deny_access unless User.current.admin?
end
if request.post?
if params[:user].is_a?(Hash)
new_attributes = params[:user][:new_rate_attributes]
existing_attributes = params[:user][:existing_rate_attributes]
end
@user.add_rates(@project, new_attributes)
@user.set_existing_rates(@project, existing_attributes)
end
if request.post? && @user.save
flash[:notice] = l(:notice_successful_update)
if @project.nil?
redirect_back_or_default(:action => 'show', :id => @user)
else
redirect_back_or_default(:action => 'show', :id => @user, :project_id => @project)
end
else
if @project.nil?
@rates = DefaultHourlyRate.find(:all,
:conditions => {:user_id => @user},
:order => "#{DefaultHourlyRate.table_name}.valid_from desc")
@rates << @user.default_rates.build({:valid_from => Date.today}) if @rates.empty?
else
@rates = @user.rates.select{|r| r.project_id == @project.id}.sort { |a,b| b.valid_from <=> a.valid_from }
@rates << @user.rates.build({:valid_from => Date.today, :project_id => @project}) if @rates.empty?
end
render :action => "edit", :layout => !request.xhr?
end
end
def set_rate
today = Date.today
rate = @user.rate_at(today, @project)
rate ||= HourlyRate.new(:project => @project, :user => @user, :valid_from => today)
rate.rate = clean_currency(params[:rate]).to_f
if rate.save
if request.xhr?
render :update do |page|
if User.current.allowed_to?(:change_rates, @project) || User.current.allowed_to?(:view_all_rates, @project) || User.current = @user && User.current.allowed_to?(:view_own_rate, @project)
page.replace_html "rate_for_#{@user.id}", link_to(number_to_currency(rate.rate), :action => User.current.allowed_to?(:change_rates, @project) ? 'edit' : 'show', :id => @user, :project_id => @project)
end
end
else
flash[:notice] = l(:notice_successful_update)
redirect_to :action => 'index'
end
end
end
private
def find_project
@project = Project.find(params[:project_id])
rescue ActiveRecord::RecordNotFound
render_404
end
def find_optional_project
@project = params[:project_id].blank? ? nil : Project.find(params[:project_id])
rescue ActiveRecord::RecordNotFound
render_404
end
def find_user
@user = User.find(params[:id])
rescue ActiveRecord::RecordNotFound
render_404
end
end

@ -0,0 +1,53 @@
require 'csv'
module CostObjectsHelper
include ApplicationHelper
# Check if the current user is allowed to manage the budget. Based on Role
# permissions.
def allowed_management?
return User.current.allowed_to?(:edit_cost_objects, @project)
end
def cost_objects_to_csv(cost_objects, project)
ic = Iconv.new(l(:general_csv_encoding), 'UTF-8')
decimal_separator = l(:general_csv_decimal_separator)
export = StringIO.new
CSV::Writer.generate(export, l(:general_csv_separator)) do |csv|
# csv header fields
headers = [ "#",
l(:field_status),
l(:field_project),
l(:field_subject),
l(:field_author),
l(:field_fixed_date),
l(:field_material_budget),
l(:field_labor_budget),
l(:field_spent),
l(:field_created_on),
l(:field_updated_on),
l(:field_description)
]
csv << headers.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
# csv lines
cost_objects.each do |cost_object|
fields = [cost_object.id,
l(cost_object.status),
cost_object.project.name,
cost_object.subject,
cost_object.author.name,
format_date(cost_object.fixed_date),
cost_object.kind == "VariableCostObject" ? number_to_currency(cost_object.material_budget) : "",
cost_object.kind == "VariableCostObject" ? number_to_currency(cost_object.labor_budget) : "",
cost_object.kind == "VariableCostObject" ? number_to_currency(cost_object.spent) : "",
format_time(cost_object.created_on),
format_time(cost_object.updated_on),
cost_object.description
]
csv << fields.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
end
end
export.rewind
export
end
end

@ -0,0 +1,34 @@
module CostReportsHelper
include QueriesHelper
def operators_for_select(type_name)
CostQuery.filter_types[type_name][:operators].collect {|o| [l(CostQuery.operators[o][:label]), o]}
end
def scope_icon_class(filter)
case filter.scope
when :issues
"wide-icon single-wide-icon icon-ticket"
when :costs
applies = @query.available_filters[filter.scope][filter.column_name][:applies]
return "wide-icon" if applies.nil? || applies.empty?
if applies.length > 1
"wide-icon icon-money-time"
else
case applies[0]
when :time_entries
"wide-icon single-wide-icon icon-time"
when :cost_entries
"wide-icon single-wide-icon icon-money"
end
end
end
end
def js_reorder_links(name, function)
link_to_function(image_tag('2uparrow.png', :alt => l(:label_sort_highest)), "#{function}('#{escape_javascript(name)}', 'highest')", :title => l(:label_sort_highest)) +
link_to_function(image_tag('1uparrow.png', :alt => l(:label_sort_higher)), "#{function}('#{escape_javascript(name)}', 'higher')", :title => l(:label_sort_higher)) +
link_to_function(image_tag('1downarrow.png', :alt => l(:label_sort_lower)), "#{function}('#{escape_javascript(name)}', 'lower')", :title => l(:label_sort_lower)) +
link_to_function(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)), "#{function}('#{escape_javascript(name)}', 'lowest')", :title => l(:label_sort_lowest))
end
end

@ -0,0 +1,3 @@
module CostTypesHelper
include CostlogHelper
end

@ -0,0 +1,98 @@
module CostlogHelper
include TimelogHelper
def render_costlog_breadcrumb
links = []
links << link_to(l(:label_project_all), {:project_id => nil, :issue_id => nil})
links << link_to(h(@project), {:project_id => @project, :issue_id => nil}) if @project
links << link_to_issue(@issue) if @issue
breadcrumb links
end
def cost_types_collection_for_select_options(selected_type = nil)
cost_types = CostType.find(:all, :conditions => {:deleted_at => nil}).sort
if selected_type && !cost_types.include?(selected_type)
cost_types << selected_type
cost_types.sort
end
collection = []
collection << [ "--- #{l(:actionview_instancetag_blank_option)} ---", '' ] unless cost_types.detect(&:is_default?)
cost_types.each { |t| collection << [t.name, t.id] }
collection
end
def user_collection_for_select_options(options = {})
users = @project.assignable_users
collection = []
users.each { |u| collection << [u.name, u.id] }
collection
end
def entries_to_csv(entries)
# TODO
raise( NotImplementedError, "entries_to_csv is not implemented yet" )
ic = Iconv.new(l(:general_csv_encoding), 'UTF-8')
decimal_separator = l(:general_csv_decimal_separator)
export = StringIO.new
CSV::Writer.generate(export, l(:general_csv_separator)) do |csv|
# csv header fields
headers = [l(:field_spent_on),
l(:field_user),
l(:field_project),
l(:field_issue),
l(:field_tracker),
l(:field_subject),
l(:field_comments),
l(:field_cost_type),
l(:field_unit_price),
l(:field_units),
l(:field_overall_costs)
]
csv << headers.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
# csv lines
entries.each do |entry|
fields = [format_date(entry.spent_on),
entry.user,
entry.project,
(entry.issue ? entry.issue.id : nil),
(entry.issue ? entry.issue.tracker : nil),
(entry.issue ? entry.issue.subject : nil),
entry.comments,
entry.cost_type.name,
User.current.allowed_to?(:view_unit_price, entry.project) ? entry.cost_type.unit_price.to_s.gsub('.', decimal_separator): "-",
entry.units.to_s.gsub('.', decimal_separator),
User.current.allowed_to?(:view_unit_price, entry.project) ? entry.cost.to_s.gsub('.', decimal_separator): "-"
]
csv << fields.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
end
end
export.rewind
export
end
def extended_progress_bar(pcts, options={})
return progress_bar(pcts, options) unless pcts.is_a?(Numeric) && pcts > 100
width = options[:width] || '100px;'
legend = options[:legend] || ''
content_tag('table',
content_tag('tr',
content_tag('td', '', :style => "width: #{((100 / pcts) * 100).round}%;", :class => 'closed') +
content_tag('td', '', :style => "width: #{100 - ((100 / pcts) + 100).round}%;", :class => 'exceeded')
), :class => 'progress', :style => "width: #{width};") +
content_tag('p', legend, :class => 'pourcent')
end
def clean_currency(value)
return nil if value.nil? || value == ""
value = value.strip
value.gsub!(l(:currency_delimiter), '') if value.include?(l(:currency_delimiter)) && value.include?(l(:currency_separator))
value.gsub(',', '.')
end
end

@ -0,0 +1,3 @@
module HourlyRatesHelper
include CostlogHelper
end

@ -0,0 +1,121 @@
class CostEntry < ActiveRecord::Base
belongs_to :project
belongs_to :issue
belongs_to :user
belongs_to :cost_type
belongs_to :cost_object
belongs_to :rate, :class_name => "CostRate"
attr_protected :project_id, :costs, :rate_id
validates_presence_of :project_id, :issue_id, :user_id, :cost_type_id, :units, :spent_on, :issue
validates_numericality_of :units, :allow_nil => false, :message => :activerecord_error_invalid
validates_length_of :comments, :maximum => 255, :allow_nil => true
def after_initialize
if new_record? && self.cost_type.nil?
if default_cost_type = CostType.default
self.cost_type_id = default_cost_type.id
end
end
end
def before_validation
self.project = issue.project if issue && project.nil?
end
def validate
errors.add :units, :activerecord_error_invalid if units && (units < 0)
errors.add :project_id, :activerecord_error_invalid if project.nil?
errors.add :issue_id, :activerecord_error_invalid if (issue_id && !issue) || (issue && project!=issue.project)
errors.add :user_id, :activerecord_error_invalid unless (user == User.current) || (User.current.allowed_to? :book_costs, project)
begin
spent_on.to_date
rescue Exception
errors.add :spent_on, :activerecord_error_invalid
end
end
def before_save
self.spent_on &&= spent_on.to_date
result = update_costs
return issue.changed? ? result : issue.save
end
# tyear, tmonth, tweek assigned where setting spent_on attributes
# these attributes make time aggregations easier
def spent_on=(date)
super
self.tyear = spent_on ? spent_on.year : nil
self.tmonth = spent_on ? spent_on.month : nil
self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil
end
def real_costs
# This methods returns the actual assigned costs of the entry
self.overridden_costs || self.costs || self.calculated_costs
end
def calculated_costs(rate_attr = nil)
rate_attr ||= current_rate
units * rate_attr.rate
rescue
0.0
end
def update_costs(rate_attr = nil)
rate_attr ||= current_rate
if rate_attr.nil?
self.costs = 0.0
self.rate = nil
return
end
self.costs = self.calculated_costs(rate_attr)
self.rate = rate_attr
if self.overridden_costs_changed?
if self.overridden_costs_was.nil?
# just started to overwrite the cost
delta = self.costs_was.nil? ? self.overridden_costs : self.overridden_costs - self.costs_was
elsif self.overridden_costs.nil?
# removed the overridden cost, use the calculated cost now
delta = self.costs - self.overridden_costs_was
else
# changed the overridden costs
delta = self.overridden_costs - self.overridden_costs_was
end
elsif self.costs_changed? && self.overridden_costs.nil?
# we use the calculated costs and it has changed
delta = self.costs - (self.costs_was || 0.0)
end
self.issue.material_costs += delta if delta
# save the current rate
@updated_rate = rate_attr.id
@updated_units = self.units
end
def update_costs!(rate_attr = nil)
self.update_costs(rate_attr)
self.save!
end
def current_rate
self.cost_type.rate_at(self.spent_on)
end
# Returns true if the time entry can be edited by usr, otherwise false
def editable_by?(usr)
(usr == user && usr.allowed_to?(:edit_own_cost_entries, project)) || usr.allowed_to?(:edit_cost_entries, project)
end
def self.visible_by(usr)
with_scope(:find => { :conditions => Project.allowed_to_condition(usr, :view_cost_entries) }) do
yield
end
end
end

@ -0,0 +1,145 @@
# A CostObject is an item that is created as part of the project. These items
# contain a collection of issues.
class CostObject < ActiveRecord::Base
belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
belongs_to :project
has_many :issues
attr_protected :author
acts_as_attachable :after_remove => :attachment_removed
acts_as_event :title => Proc.new {|o| "#{l(:label_cost_object)} ##{o.id}: #{o.subject}"},
:url => Proc.new {|o| {:controller => 'cost_objects', :action => 'show', :id => o.id}}
acts_as_activity_provider :find_options => {:include => [:project, :author]},
:timestamp => "#{table_name}.updated_on",
:author_key => :author_id
validates_presence_of :subject, :project, :author, :kind
validates_length_of :subject, :maximum => 255
def before_validation
self.author_id = User.current.id if self.new_record?
end
def before_destroy
issues.all.each do |i|
result = i.update_attributes({:cost_object => nil})
return false unless result
end
end
def attributes=(attrs)
# Remove any attributes which can not be assigned.
# This is to protect from exceptions during change of cost object type
attrs.delete_if{|k, v| !self.respond_to?("#{k}=")} if attrs.is_a?(Hash)
super(attrs)
end
def copy_from(arg)
cost_object = arg.is_a?(CostObject) ? arg : CostObject.find(arg)
self.attributes = cost_object.attributes.dup
end
# Wrap type column to make it usable in views (especially in a select tag)
def kind
self[:type]
end
def kind=(type)
self[:type] = type
end
# Assign all the issues with +version_id+ to this Cost Object
def assign_issues_by_version(version_id)
version = Version.find_by_id(version_id)
return 0 if version.nil? || version.fixed_issues.blank?
version.fixed_issues.each do |issue|
issue.update_attribute(:cost_object_id, self.id)
end
return version.fixed_issues.size
end
# Change the Cost Object type to another type. Valid types are
#
# * FixedCostObject
# * VariableCostObject
def change_type(to)
if [FixedCostObject.name, VariableCostObject.name].include?(to)
self.type = to
self.save!
return CostObject.find(self.id)
else
return self
end
end
# Amount spent. Virtual accessor that is overriden by subclasses.
def spent
0
end
def spent_for_display
if User.current.allowed_to?(:view_all_rates, project) && User.current.allowed_to?(:view_unit_price, project)
spent
else
000
end
end
# Budget of labor. Virtual accessor that is overriden by subclasses.
def labor_budget
0
end
def labor_budget_for_display
User.current.allowed_to?(:view_all_rates, project) ? labor_budget : 0.0
end
# Budget of material, i.e. all costs besides labor costs. Virtual accessor that is overriden by subclasses.
def material_budget
0
end
def material_budget_for_display
User.current.allowed_to?(:view_unit_price, project) ? material_budget : 0.0
end
def budget
material_budget + labor_budget
end
def budget_for_display
User.current.allowed_to?(:view_all_rates, project) && User.current.allowed_to?(:view_unit_price, project) ? budget : 0.0
end
def status
# this just returns the symbol for I18N
if project_manager_signoff
client_signoff ? :label_status_finished : :label_status_awaiting_client
else
client_signoff ? :label_status_awaiting_client : :label_status_in_progress
end
end
# Label of the current type for display in GUI. Virtual accessor that is overriden by subclasses.
def type_label
return l(:label_cost_object)
end
# Amount of the budget spent. Expressed as as a percentage whole number
def budget_ratio
return 0.0 if self.budget.nil? || self.budget == 0.0
return ((self.spent / self.budget) * 100).round
end
def css_classes
return "issue"
end
end

@ -0,0 +1,40 @@
require_dependency "entry"
class CostQuery < ActiveRecord::Base
#include GLoc
#belongs_to :user
#belongs_to :project
#attr_protected :user_id, :project_id, :created_at, :updated_at
def self.accepted_properties
@accepted_properties ||= []
end
def results
chain.results
end
def add_chain(type, name, options)
chain type.const_get(name.to_s.camelcase), options
end
def chain(klass = nil, options = {})
@chain ||= Filter::NoFilter.new
@chain = klass.new @chain, options if klass
@chain = @chain.parent until @chain.top?
@chain
end
def filter(name, options = {})
add_chain Filter, name, options
end
def group_by(name, options = {})
add_chain GroupBy, name, options
end
def method_missing(*a, &b)
chain.send(*a, &b)
end
end

@ -0,0 +1,199 @@
# Proviedes convinience layer and logic shared between GroupBy::Base and Filter::Base.
# Implements a dubble linked list (FIXME: is that the correct term?).
class CostQuery < ActiveRecord::Base
class Chainable
include CostQuery::QueryUtils
def self.accepts_property(*list)
CostQuery.accepted_properties.push(*list.map(&:to_s))
end
def self.inherited_attribute(*attributes, &block)
options = attributes.extract_options!
list = options[:list]
default = options[:default]
uniq = options[:uniq]
map = options[:map] || proc { |e| e }
default ||= [] if list
attributes.each do |name|
define_singleton_method(name) do |*values|
return get_inherited_attribute(name, default, list, uniq) if values.empty?
return set_inherited_attribute(name, values.map(&map)) if list
raise ArgumentError, "wrong number of arguments (#{values.size} for 1)" if values.size > 1
set_inherited_attribute name, map.call(values.first)
end
define_method(name) { |*values| self.class.send(name, *values) }
end
end
def self.define_singleton_method(name, &block)
attr_writer name
metaclass.class_eval { define_method(name, &block) }
define_method(name) { instance_variable_get("@#{name}") or metaclass.send(name) }
end
def self.get_inherited_attribute(name, default = nil, list = false, uniq = false)
return get_inherited_attribute(name, default, list, false).uniq if list and uniq
result = instance_variable_get("@#{name}")
super_result = superclass.get_inherited_attribute(name, default, list) if superclass.respond_to? :get_inherited_attribute
if result
list && super_result ? result + super_result : result
else
super_result || default
end
end
def self.set_inherited_attribute(name, value)
instance_variable_set "@#{name}", value
end
def self.chain_list(*list)
options = list.extract_options!
options[:list] = true
list << options
inherited_attribute(*list)
end
def self.base?
superclass == Chainable or self == Chainable
end
def self.base
return self if base?
super
end
def self.from_base(&block)
base.instance_eval(&block)
end
def self.available
from_base { @available ||= [] }
end
def self.register(label)
available << klass
set_inherited_attribute "label", label
end
def self.table_joins
@table_joins ||= []
end
def self.table_from(value)
return value unless value.respond_to? :to_ary or value.respond_to? :to_hash
table_from value.to_a.first
end
def self.join_table(*args)
@last_table = table_from(args.last)
table_joins << args
end
inherited_attribute :label
inherited_attribute :properties, :list => true
class << self
alias inherited_attributes inherited_attribute
alias accepts_properties accepts_property
end
attr_accessor :parent
attr_reader :child
def child=(obj)
@child = obj
end
def to_a
returning([to_hash]) { |a| a.unshift(*child.to_a) unless bottom? }
end
def top
return self if top?
parent.top
end
def top?
parent.nil?
end
def bottom?
child.nil?
end
def bottom
return self if bottom?
child.bottom
end
def initialize(child = nil, options = {})
@child, child.parent = child, self if child
options.each do |key, value|
raise ArgumentError, "may not set #{key}" unless CostQuery.accepted_properties.include? key.to_s
send "#{key}=", value
end
until correct_position?
child_was = child
child_was.parent, self.parent = parent, child_was
child_was.child, self.child = self, child.child
end
end
def chain_collect(name, *args, &block)
top.subchain_collect(name, *args, &block)
end
# See #chain_collect
def subchain_collect(name, *args, &block)
subchain = child.subchain_collect(name, *args, &block) unless bottom?
[* send(name, *args, &block) ].push(*subchain).compact.uniq
end
# overwrite in subclass to maintain constisten state
# ie automatically turning
# FilterFoo.new(GroupByFoo.new(FilterBar.new))
# into
# GroupByFoo.new(FilterFoo.new(FilterBar.new))
# Returning false will make the
def correct_position?
true
end
def result
Result.new ActiveRecord::Base.connection.select_all(sql_statement.to_s)
end
def table_joins
self.class.table_joins
end
def sql_statement
raise "should not get here (#{inspect})" if bottom?
child.sql_statement.tap { |q| chain_collect(:table_joins).each { |args| q.join(*args) } if responsible_for_sql? }
end
inherited_attributes :db_field, :display
def self.field
db_field || name[/[^:]+$/].underscore
end
def self.last_table
@last_table ||= 'entries'
end
def last_table
self.class.last_table
end
def with_table(fields)
fields.map { |f| field_name_for f, last_table }
end
def field
self.class.field
end
end
end

@ -0,0 +1,13 @@
require "set"
module CostQuery::Filter
def self.all
@all ||= Set.new
end
def self.from_hash
raise NotImplementedError
end
end

@ -0,0 +1,5 @@
class CostQuery::Filter::ActivityId < CostQuery::Filter::Base
def available_values
Activity.all.map { |a| [a.name, a.id] }
end
end

@ -0,0 +1,8 @@
class CostQuery::Filter::AssignedToId < CostQuery::Filter::Base
null_operators
join_table Issue
def available_values
User.all.map { |u| [u.name, u.id] }
end
end

@ -0,0 +1,124 @@
module CostQuery::Filter
class Base < CostQuery::Chainable
CostQuery::Operator.load
inherited_attribute :available_operators,
:list => true, :map => :to_operator,
:uniq => true
inherited_attribute :default_operator, :map => :to_operator
accepts_property :values, :value, :operator
attr_accessor :values
def value=(val)
self.values = [val]
end
def self.default_operators
available_operators "=", "!"
end
default_operators
def self.date_operators
available_operators "<>d", ">d", "<d", "=d"
end
def self.time_operators
available_operators :t, :w, "t-", "t+", ">t-", "<t-", ">t+", "<t+"
end
def self.string_operators
available_operators "!~", "~"
end
def self.null_operators
available_operators "*", "!*"
end
def self.integer_operators
available_operators "<", ">", "<=", ">="
end
def self.new(*args, &block) # :nodoc:
# this class is abstract. instances are only allowed from child classes
raise "#{self.name} is an abstract class" if base?
super
end
def self.inherited(klass)
if base?
CostQuery::Filter.all << klass
self.dont_display!
klass.display!
end
super
end
def self.display!
display true
end
def self.display?
!!display
end
def self.dont_display!
display false
end
def correct_position?
child.nil? or child.is_a? CostQuery::Filter::Base
end
def from_for(scope)
super + self.class.table_joins
end
def available_values(project)
raise NotImplementedError, "subclass responsibility"
end
def filter?
true
end
def group_by_fields
[]
end
def initialze(child = nil, options = {})
raise ArgumentError, "Child has to be a Filter." if child and not child.filter?
@values = []
super
end
def might_be_responsible
parent
end
def operator
(@operator || self.class.default_operator || CostQuery::Operator.default_operator).to_operator
end
def operator=(value)
@operator = value.to_operator.tap do |o|
raise ArgumentError, "#{o.inspect} not supported by #{inspect}." unless available_operators.include? o
end
end
def responsible_for_sql?
top?
end
def to_hash
raise NotImplementedError
end
def sql_statement
super.tap do |query|
operator.modify(query, field, *values)
end
end
end
end

@ -0,0 +1,8 @@
class CostQuery::Filter::CategoryId < CostQuery::Filter::Base
null_operators
join_table Issue
def available_values
IssueCategory.all.map { |c| [c.name, c.id] }
end
end

@ -0,0 +1,7 @@
class CostQuery::Filter::CostTypeId < CostQuery::Filter::Base
available_operators
def available_values
CostType.all.map { |t| [t.name, t.id] }
end
end

@ -0,0 +1,3 @@
class CostQuery::Filter::CreatedOn < CostQuery::Filter::Base
time_operators
end

@ -0,0 +1,4 @@
class CostQuery::Filter::DueDate < CostQuery::Filter::Base
date_operators
join_table Issue
end

@ -0,0 +1,4 @@
class CostQuery::Filter::FixedVersionId < CostQuery::Filter::Base
null_operators
join_table Issue
end

@ -0,0 +1,5 @@
class CostQuery::Filter::IssueId < CostQuery::Filter::Base
def available_values
Issue.all.map { |i| [i.name, i.id] }
end
end

@ -0,0 +1,5 @@
class CostQuery::Filter::NoFilter < CostQuery::Filter::Base
def sql_statement
CostQuery::SqlStatement.for_entries
end
end

@ -0,0 +1,3 @@
class CostQuery::Filter::OverriddenCosts < CostQuery::Filter::Base
available_operators 'y', 'n'
end

@ -0,0 +1,3 @@
class CostQuery::Filter::PriorityId < CostQuery::Filter::Base
join_table Issue
end

@ -0,0 +1,5 @@
class CostQuery::Filter::ProjectId < CostQuery::Filter::Base
def available_values
Project.all.map { |p| [p.name, p.id] }
end
end

@ -0,0 +1,3 @@
class CostQuery::Filter::SpentOn < CostQuery::Filter::Base
time_operators
end

@ -0,0 +1,4 @@
class CostQuery::Filter::StartDate < CostQuery::Filter::Base
date_operators
join_table Issue
end

@ -0,0 +1,4 @@
class CostQuery::Filter::StatusId < CostQuery::Filter::Base
available_operators 'c', 'o'
join_table Issue, IssueStatus => [Issue, :status]
end

@ -0,0 +1,3 @@
class CostQuery::Filter::Subject < CostQuery::Filter::Base
join_table Issue
end

@ -0,0 +1,3 @@
class CostQuery::Filter::TrackerId < CostQuery::Filter::Base
join_table Issue
end

@ -0,0 +1,3 @@
class CostQuery::Filter::UpdatedOn < CostQuery::Filter::Base
time_operators
end

@ -0,0 +1,5 @@
class CostQuery::Filter::UserId < CostQuery::Filter::Base
def available_values
User.all.map { |u| [u.name, u.id] }
end
end

@ -0,0 +1,11 @@
require "set"
module CostQuery::GroupBy
def self.all
@all ||= Set.new
end
def self.from_hash
raise NotImplementedError
end
end

@ -0,0 +1,4 @@
module CostQuery::GroupBy
class ActivityId < Base
end
end

@ -0,0 +1,37 @@
module CostQuery::GroupBy
class Base < CostQuery::Chainable
inherited_attributes :group_fields, :list => true
def self.inherited(klass)
klass.group_fields klass.field
super
end
def filter?
false
end
def sql_aggregation?
child.filter?
end
def all_group_fields
(parent ? parent.all_group_fields : []) + with_table(group_fields)
end
def aggregation_mixin
sql_aggregation? ? SqlAggregation : RubyAggregation
end
def initialize(child = nil, optios = {})
super
extend aggregation_mixin
end
def define_group(sql)
fields = all_group_fields.uniq
sql.group_by fields
sql.select fields
end
end
end

@ -0,0 +1,5 @@
module CostQuery::GroupBy
class CostObjectId < Base
join_table Issue
end
end

@ -0,0 +1,4 @@
module CostQuery::GroupBy
class CostTypeId < Base
end
end

@ -0,0 +1,4 @@
module CostQuery::GroupBy
class IssueId < Base
end
end

@ -0,0 +1,4 @@
module CostQuery::GroupBy
class ProjectId < Base
end
end

@ -0,0 +1,15 @@
module CostQuery::GroupBy
module RubyAggregation
def responsible_for_sql?
false
end
def result
data = child.result.group_by do |entry|
group_fields.inject({}) { |hash, key| hash.merge key => entry.fields[key] }
end
list = data.keys.map { |fields| CostQuery::Result.new data[fields], fields }
CostQuery::Result.new list
end
end
end

@ -0,0 +1,4 @@
module CostQuery::GroupBy
class SpentOn < Base
end
end

@ -0,0 +1,15 @@
module CostQuery::GroupBy
module SqlAggregation
def responsible_for_sql?
true
end
def sql_statement
super.tap do |sql|
define_group sql
sql.sum :units => :units, :real_costs => :real_costs
sql.count
end
end
end
end

@ -0,0 +1,4 @@
module CostQuery::GroupBy
class Tmonth < Base
end
end

@ -0,0 +1,5 @@
module CostQuery::GroupBy
class TrackerId < Base
join_table Issue
end
end

@ -0,0 +1,4 @@
module CostQuery::GroupBy
class Tweek < Base
end
end

@ -0,0 +1,4 @@
module CostQuery::GroupBy
class Tyear < Base
end
end

@ -0,0 +1,4 @@
module CostQuery::GroupBy
class UserId < Base
end
end

@ -0,0 +1,247 @@
class CostQuery::Operator
include CostQuery::QueryUtils
#############################################################################################
# Wrapped so we can place this at the top of the file.
def self.define_operators # :nodoc:
# Defaults
defaults do
def sql_operator
name
end
def where_clause
"%s %s '%s'"
end
def modify(query, field, *values)
query.where [where_clause, field, sql_operator, *values]
query
end
def label
@label ||= Query.operators[name]
end
end
# Operators from Redmine
new ">t-" do
include DateRange
def modify(query, field, value)
super query, field, -value.to_i, 0
end
end
new "w" do
def modify(query, field, offset = nil)
offset ||= 0
from = Time.now.at_beginning_of_week - ((l(:general_first_day_of_week).to_i % 7) + 1).days
from -= offset.days
'<>d'.to_operator.modify query, field, from, from + 7.days
end
end
new "t+" do
include DateRange
def modify(query, field, *values)
super query, field, values.first.to_i, values.first.to_i
end
end
new "<="
new "!" do
def modify(query, field, *values)
query.where "(#{field} IS NULL OR #{field} NOT IN #{collection(*values)})"
query
end
end
new "t-" do
include DateRange
def modify(query, field, *values)
super query, field, -values.first.to_i, -values.first.to_i
end
end
new "c" do
def modify(query, field, *values)
raise "wrong field" if field.to_s.split('.').last != "status_id"
"=".to_operator.modify(query, "#{IssueStatus.table_name}.is_closed", quoted_true)
end
end
new "o" do
def modify(query, field, *values)
raise "wrong field" if field.to_s.split('.').last != "status_id"
"=".to_operator.modify(query, "#{IssueStatus.table_name}.is_closed", quoted_false)
end
end
new "!~" do
def modify(query, field, value)
query.where "LOWER(#{field}) NOT LIKE '%#{quote_string(value.to_s.downcase)}%'"
query
end
end
new "=" do
def modify(query, field, *values)
query.where "#{field} IN #{collection(*values)}"
query
end
end
new "~" do
def modify(query, field, value)
query.where "LOWER(#{field}) LIKE '%#{quote_string(value.to_s.downcase)}%'"
query
end
end
new "<t+" do
include DateRange
def modify(query, field, value)
super query, field, 0, value.to_i
end
end
new "t" do
include DateRange
def modify(query, field, value=nil)
super query, field, 0, 0
end
end
new ">=" # done
new "!*", :where_clause => "%s IS NULL"
new "<t-" do
include DateRange
def modify(query, field, value)
super query, field, nil, -value.to_i
end
end
new ">t+" do
include DateRange
def modify(query, field, value)
super query, field, value.to_i, nil
end
end
new "*", :where_clause => "%s IS NOT NULL"
# Our own operators
new "<", :label => :label_less
new ">", :label => :label_greater
new "=n", :label => :label_equals do
def modify(query, field, value)
query.where "#{field} = #{clean_currency(value)}"
query
end
end
new "0", :label => :label_none, :where_clause => "%s = 0"
new "y", :label => :label_yes, :where_clause => "%s IS NOT NULL"
new "n", :label => :label_no, :where_clause => "%s IS NULL"
new "<d", :label => :label_less_or_equal do
def modify(query, field, value)
"<".to_operator.modify query, field, quoted_date(value)
end
end
new ">d", :label => :label_greater_or_equal do
def modify(query, field, value)
">".to_operator.modify query, field, quoted_date(value)
end
end
new "<>d", :label => :label_between do
def modify(query, field, from, to)
query.where "#{field} BETWEEN '#{quoted_date from}' AND '#{quoted_date to}'"
query
end
end
new "=d", :label => :label_date_on do
def modify(query, field, value)
"=".to_operator.modify query, field, quoted_date(value)
end
end
end
#############################################################################################
module CoreExt
::String.send :include, self
::Symbol.send :include, self
def to_operator
CostQuery::Operator.find self
end
end
def self.new(name, values = {}, &block)
all[name.to_s] ||= super
end
def self.all
@all ||= {}
end
def self.load
return if @done
@done = true
define_operators
end
def self.find(name)
all[name.to_s] or raise ArgumentError, "Operator not defined"
end
def self.defaults(&block)
class_eval &block
end
def self.default_operator
find "="
end
attr_reader :name
def initialize(name, values = {}, &block)
@name = name.to_s
values.each do |key, value|
metaclass.class_eval { define_method(key) { value } }
end
metaclass.class_eval(&block) if block
end
def to_operator
self
end
def inspect
"#<#{self.class.name}:#{name.inspect}>"
end
def <=>(other)
self.name <=> other.name
end
module DateRange
def modify(query, field, from, to)
query.where ["#{field} > '%s'", quoted_date((Date.yesterday + from).to_time.end_of_day)] if from
query.where ["#{field} <= '%s'", quoted_date((Date.today + to).to_time.end_of_day)] if to
query
end
end
# Done with class method definition, let's initialize the operators
load
end

@ -0,0 +1,120 @@
require 'forwardable'
module CostQuery::QueryUtils
include Redmine::I18n
extend Forwardable
def_delegators "ActiveRecord::Base.connection", :quoted_false, :quoted_true, :quoted_date
##
# Graceful string quoting.
#
# @param [Object] str String to quote
# @return [Object] Quoted version
def quote_string(str)
return str unless str.respond_to? :to_str
ActiveRecord::Base.connection.quote_string(str)
end
##
# Graceful, internationalized quoted string.
#
# @see quote_string
# @param [Object] str String to quote/translate
# @return [Object] Quoted, translated version
def quoted_label(ident)
"'#{quote_string l(ident)}'"
end
##
# Creates a SQL fragment representing a collection/array.
#
# @see quote_string
# @param [#flatten] *values Ruby collection
# @return [String] SQL collection
def collection(*values)
"(#{values.flatten.map { |v| "'#{quote_string(v)}'" }.join ", "})"
end
##
# SQL date quoting.
# @param [Date,Time] date Date to quote.
# @return [String] Quoted date.
def quote_date(date)
"'#{quoted_date date}'"
end
##
# Generate a table name for any object.
#
# @example Table names
# table_name_for Issue # => 'issues'
# table_name_for :issue # => 'issues'
# table_name_for "issue" # => 'issues'
# table_name_for "issues" # => 'issues
#
# @param [#table_name, #to_s] object Object you need the table name for.
# @return [String] The table name.
def table_name_for(object)
return object.table_name if object.respond_to? :table_name
object.to_s.tableize
end
##
# Generate a field name
#
# @example Field names
# field_name_for nil # => 'NULL'
# field_name_for 'foo' # => 'foo'
# field_name_for [Issue, 'project_id'] # => 'issues.project_id'
# field_name_for [:issue, 'project_id'], :entry # => 'issues.project_id'
# field_name_for 'project_id', :entry # => 'entries.project_id'
#
# @param [Array, Object] arg Object to generate field name for.
# @param [Object, optional] default_table Table name to use if no table name is given.
# @return [String] Field name.
def field_name_for(arg, default_table = nil)
return 'NULL' unless arg
return table_name_for(arg.first || default_table) + '.' << arg.last.to_s if arg.is_a? Array and arg.size == 2
return arg.to_s unless default_table
field_name_for [default_table, arg]
end
##
# Sanitizes sql condition
#
# @see ActiveRecord::Base#sanitize_sql_for_conditions
# @param [Object] statement Not sanitized statement.
# @return [String] Sanitized statement.
def sanitize_sql_for_conditions(statement)
CostQuery.send :sanitize_sql_for_conditions, statement
end
##
# Generates string representation for a currency.
#
# @see CostRate.clean_currency
# @param [BigDecimal] value
# @return [String]
def clean_currency(value)
CostRate.clean_currency(value).to_f.to_s
end
##
# Generates a SQL case statement.
#
# @example
# switch "#{table}.overridden_costs IS NULL" => [model, :costs], :else => [model, :overridden_costs]
#
# @param [Hash] options Condition => Result.
# @return [String] Case statement.
def switch(options)
options = options.with_indifferent_access
else_part = options.delete :else
"CASE #{options.map { |k,v| "WHEN #{field_name_for k} THEN #{field_name_for v}" }} ELSE #{field_name_for else_part} END"
end
def self.included(klass)
super
klass.extend self
end
end

@ -0,0 +1,108 @@
require 'big_decimal_patch'
module CostQuery::Result
class Base
attr_accessor :parent
attr_reader :value
alias values value
def initialize(value)
@value = value
end
def recursive_each_with_level(level = 0, &block)
block.call(level, self)
end
def recursive_each
recursive_each_with_level { |level, result| yield result }
end
def [](key)
fields[key]
end
end
class DirectResult < Base
alias fields values
def has_children?
false
end
def count
self["count"].to_i
end
def units
self["units"].to_d
end
def real_costs
self["real_costs"].to_d
end
##
# @return [Integer] Number of child results
def size
0
end
end
class WrappedResult < Base
include Enumerable
def has_children?
true
end
def count
sum_for :count
end
def units
sum_for :units
end
def real_costs
sum_for :real_costs
end
def sum_for(field)
@sum_for ||= {}
@sum_for[field] ||= inject(0) { |a,v| a + v.send(field) }
end
def recursive_each_with_level(level = 0, &block)
super
each { |c| c.recursive_each_with_level(level + 1, &block) }
end
def each(&block)
values.each(&block)
end
def fields
@fields ||= {}.with_indifferent_access
end
##
# @return [Integer] Number of child results
def size
values.size
end
end
def self.new(value, fields = {})
result = begin
case value
when Array then WrappedResult.new value.map { |e| new e }
when Hash then DirectResult.new value.with_indifferent_access
when Base then value
else raise ArgumentError, "Cannot create Result from #{value.inspect}"
end
end
result.fields.merge! fields
result
end
end

@ -0,0 +1,257 @@
class CostQuery::SqlStatement
include CostQuery::QueryUtils
COMMON_FIELDS = %w[
user_id project_id issue_id rate_id
comments spent_on created_on updated_on tyear tmonth tweek
costs overridden_costs
]
##
# Generates new SqlStatement.
#
# @param [String, #to_s] table Table name (or subselect) for from part.
def initialize(table)
from table
end
##
# Generates SqlStatement that maps time_entries and cost_entries to a common structure.
#
# Mapping for direct fields:
#
# Result | Time Entires | Cost entries
# --------------------------|--------------------------|--------------------------
# id | id | id
# user_id | user_id | user_id
# project_id | project_id | project_id
# issue_id | issue_id | issue_id
# rate_id | rate_id | rate_id
# comments | comments | comments
# spent_on | spent_on | spent_on
# created_on | created_on | created_on
# updated_on | updated_on | updated_on
# tyear | tyear | tyear
# tmonth | tmonth | tmonth
# tweek | tweek | tweek
# costs | costs | costs
# overridden_costs | overridden_costs | overridden_costs
# units | hours | units
# activity_id | activity_id | NULL
# cost_type_id | -1 | cost_type_id
# type | "TimeEntry" | "CostEntry"
# count | 1 | 1
#
# Also: This _should_ handle joining activities and cost_types, as the logic differs for time_entries
# and cost_entries.
#
# @param [#table_name] model The model to map
# @return [CostQuery::SqlStatement] Generated statement
def self.unified_entry(model)
table = table_name_for model
new(table).tap do |query|
query.select COMMON_FIELDS
query.select({
:type => model.model_name.inspect, :count => 1, :id => [model, :id],
:real_costs => switch("#{table}.overridden_costs IS NULL" => [model, :costs], :else => [model, :overridden_costs])
})
send("unify_#{table}", query)
end
end
##
# Applies logic for mapping time entries to general entries structure.
#
# @param [CostQuery::SqlStatement] query The statement to adjust
def self.unify_time_entries(query)
query.select :activity_id, :units => :hours, :cost_type_id => -1
query.select :cost_type => quoted_label(:caption_labor)
end
##
# Applies logic for mapping cost entries to general entries structure.
#
# @param [CostQuery::SqlStatement] query The statement to adjust
def self.unify_cost_entries(query)
query.select :units, :cost_type_id, :activity_id => nil
query.select :cost_type => "cost_types.name"
query.join CostType
end
##
# Generates a statement based on all entries (i.e. time entries and cost entries) mapped to the general entries structure,
# and therefore usable by filters and such.
#
# @return [CostQuery::SqlStatement] Generated statement
def self.for_entries
new unified_entry(TimeEntry).union(unified_entry(CostEntry), "entries")
end
##
# Creates a uninon of the caller and the callee.
#
# @param [CostQuery::SqlStatement] other Second part of the union
# @return [String] The sql query.
def union(other, as = nil)
"((#{self}) UNION (#{other}))#{" AS #{as}" if as}"
end
##
# Adds sum(..) part to select.
#
# @param [#to_s] field Name of the field to aggregate on
# @param [#to_s] name Name of the result (defaults to sum)
def sum(field, name = :sum, type = :sum)
return sum({ name => field }, nil, type) unless field.respond_to? :to_hash
field.each { |k,v| field[k] = "#{type}(#{v})" }
select field
end
##
# Adds count(..) part to select.
#
# @param [#to_s] field Name of the field to aggregate on (defaults to *)
# @param [#to_s] name Name of the result (defaults to sum)
def count(field = "*", name = :count)
sum field, name, :count
end
##
# Generates the SQL query.
# Code looks ugly in exchange for pretty output (so one does unterstand those).
#
# @return [String] The query
def to_s
# FIXME I'm ugly
sql = "\nSELECT\n#{select.map { |e| "\t#{e}" }.join ",\n"}" \
"\nFROM\n\t#{from.gsub("\n", "\n\t")}" \
"\n#{joins.map { |e| "\t#{e}" }.join "\n"}" \
"\nWHERE #{where.join " AND "}\n"
sql << "GROUP BY #{group_by.join ', '}\n" if group_by?
sql
end
##
# @overload from
# Reads the from part.
# @return [#to_s] From part
# @overload from(table)
# Sets the from part.
# @param [#to_s] table
# @param [#to_s] From part
def from(table = nil)
table ? @from = table : @from
end
##
# Where conditions. Will be joined together by AND.
#
# @overload where
# Reads the where part
# @return [Array<#to_s>] Where clauses
# @overload where(fields)
# Adds condition to where clause
# @param [Array, Hash, String] fields Parameters passed to sanitize_sql_for_conditions.
# @see CostQuery::QueryUtils#sanitize_sql_for_conditions
def where(fields = nil)
@where ||= ["1=1"]
@where << sanitize_sql_for_conditions(fields) unless fields.nil?
@where
end
##
# @return [Array<String>] List of table joins
def joins
(@joins ||= []).tap { |j| j.uniq! }
end
##
# Adds an "left outer join" (guessing field names) to #joins.
#
# @overload join(name)
# @param [Symbol, String] name Singular table name to join with, will join plural from on table.id = table_id
# @overload join(model)
# @param [#table_name, #model_name] model ActiveRecord model to join with
# @overload join(hash)
# @param [Hash<#to_s => #to_s>] hash Key is singular table name to join with, value is field to join on
# @overload join(*list)
# @param [Array<String,Symbol,Array>] list Will generate join entries (according to guessings described above)
# @see #joins
def join(*list)
join_syntax = "LEFT OUTER JOIN %1$s ON %1$s.id = %2$s_id"
list.each do |e|
case e
when Class then joins << (join_syntax % [table_name_for(e), e.model_name.underscore])
when Symbol, String then joins << (join_syntax % [table_name_for(e), e])
when Hash then e.each { |k,v| joins << (join_syntax % [table_name_for(k), field_name_for(v)]) }
when Array then join(*e)
else raise ArgumentError, "cannot join #{e.inspect}"
end
end
end
##
# @overload select
# @return [Array<String>] All fields/statements for select part
#
# @overload select(*fields)
# Adds fields to select query.
# @example
# SqlStatement.new.select(some_sql_statement) # => [some_sql_statement.to_s]
# SqlStatement.new.select("sum(foo)") # => ["foo"]
# SqlStatement.new.select(:a).select(:b) # => ["a", "b"]
# SqlStatement.new.select(:bar => :foo) # => ["foo as bar"]
# SqlStatement.new.select(:bar => nil) # => ["NULL as bar"]
# @param [Array, Hash, String, Symbol, SqlStatement] fields Fields to add to select part
# @return [Array<String>] All fields/statements for select part
def select(*fields)
return(@select || ["*"]) if fields.empty?
returning(@select ||= []) do
fields.each do |f|
case f
when Array
if f.size == 2 and f.first.respond_to? :table_name then select field_name_for(f)
else select(*f)
end
when Hash then select f.map { |k,v| "#{field_name_for v} as #{field_name_for k}" }
when String, Symbol then @select << field_name_for(f)
when CostQuery::SqlStatement then @select << f.to_s
else raise ArgumentError, "cannot handle #{f.inspect}"
end
end
# when doing a union in sql, both subselects must have the same order.
# by sorting here we never ever have to worry about this again, sucker!
@select = @select.uniq.sort_by { |x| x.split(" as ").last }
end
end
##
# @overload group_by
# @return [Array<String>] All fields/statements for group by part
#
# @overload group(*fields)
# Adds fields to group by query
# @param [Array, String, Symbol] fields Fields to add
def group_by(*fields)
returning(@group_by ||= []) do
fields.each do |e|
if e.is_a? Array and (e.size != 2 or !e.first.respond_to? :table_name)
group_by(*e)
else
@group_by << field_name_for(e)
end
end
end
end
##
# @return [TrueClass, FalseClass] Whether or not to add a group by part.
def group_by?
!group_by.empty?
end
def inspect
"#<SqlStatement: #{to_s.inspect}>"
end
end

@ -0,0 +1,28 @@
class CostRate < Rate
belongs_to :cost_type
validates_uniqueness_of :valid_from, :scope => :cost_type_id
def validate
# Only allow change of project and user on first creation
return if self.new_record?
errors.add :cost_type_id, :activerecord_error_invalid if cost_type_id_changed?
end
def previous(reference_date = self.valid_from)
# This might return a default rate
self.cost_type.rate_at(reference_date - 1)
end
def next(reference_date = self.valid_from)
CostRate.find(
:first,
:conditions => [ "cost_type_id = ? and valid_from > ?",
self.cost_type_id, reference_date],
:order => "valid_from ASC"
)
end
end

@ -0,0 +1,74 @@
class CostType < ActiveRecord::Base
has_many :material_budget_items
has_many :cost_entries, :dependent => :destroy
has_many :rates, :class_name => "CostRate", :foreign_key => "cost_type_id", :dependent => :destroy
validates_presence_of :name, :unit, :unit_plural
validates_uniqueness_of :name
after_update :save_rates
# finds the default CostType
def self.default
result = CostType.find(:first, :conditions => { :default => true})
result ||= CostType.find(:first)
result
rescue ActiveRecord::RecordNotFound
nil
end
def is_default?
self.default
end
def <=>(cost_type)
name.downcase <=> cost_type.name.downcase
end
def current_rate
rate_at(Date.today)
end
def rate_at(date)
CostRate.find(:first, :conditions => [ "cost_type_id = ? and valid_from <= ?", id, date], :order => "valid_from DESC")
rescue ActiveRecord::RecordNotFound
return nil
end
def to_s
name
end
def new_rate_attributes=(rate_attributes)
rate_attributes.each do |index, attributes|
attributes[:rate] = Rate.clean_currency(attributes[:rate])
rates.build(attributes) if attributes[:rate].to_f > 0
end
end
def existing_rate_attributes=(rate_attributes)
rates.reject(&:new_record?).each do |rate|
attributes = rate_attributes[rate.id.to_s]
has_rate = false
if attributes && attributes[:rate]
attributes[:rate] = Rate.clean_currency(attributes[:rate])
has_rate = attributes[:rate].to_f > 0
end
if has_rate
rate.attributes = attributes
else
rates.delete(rate)
end
end
end
def save_rates
rates.each do |rate|
rate.save(false)
end
end
end

@ -0,0 +1,33 @@
class DefaultHourlyRate < Rate
belongs_to :user
validates_uniqueness_of :valid_from, :scope => :user_id
validates_presence_of :user_id, :valid_from
def validate
# Only allow change of user on first creation
errors.add :user_id, :activerecord_error_invalid if !self.new_record? and user_id_changed?
begin
valid_from.to_date
rescue Exception
errors.add :valid_from, :activerecord_error_invalid
end
end
def next(reference_date = self.valid_from)
DefaultHourlyRate.find(
:first,
:conditions => [ "user_id = ? and valid_from > ?",
self.user_id, reference_date],
:order => "valid_from ASC"
)
end
def previous(reference_date = self.valid_from)
self.user.default_rate_at(reference_date - 1)
end
def before_save
self.valid_from &&= valid_from.to_date
end
end

@ -0,0 +1,86 @@
class DefaultHourlyRateObserver < ActiveRecord::Observer
class Methods
def initialize(changed_rate)
@rate = changed_rate
end
def order_dates(date1, date2)
# order the dates
return date1 || date2 if date1.nil? || date2.nil?
if date2 < date1
date_tmp = date2
date2 = date1
date1 = date_tmp
end
[date1, date2]
end
def orphaned_child_entries(date1, date2 = nil)
# This method returns all entries in all projects without an explicit rate
# between date1 and date2
# i.e. the ones with an assigned default rate or without a rate
(date1, date2) = order_dates(date1, date2)
# This gets an array of all the ids of the DefaultHourlyRates
default_rates = DefaultHourlyRate.find(:all, :select => :id).inject([]){|r,d|r<<d.id}
if date1.nil? || date2.nil?
# we have only one date, query >=
conditions = [
"user_id = ? AND (rate_id IN (?) OR rate_id IS NULL) AND spent_on >= ?",
@rate.user_id, default_rates, date1 || date2
]
else
# we have two dates, query between
conditions = [
"user_id = ? AND (rate_id IN (?) OR rate_id IS NULL) AND spent_on BETWEEN ? AND ?",
@rate.user_id, default_rates, date1, date2 - 1
]
end
TimeEntry.find(:all, :conditions => conditions, :include => :rate)
end
def update_entries(entries, rate = @rate)
# This methods updates the given array of time or cost entries with the given rate
entries = [entries] unless entries.is_a?(Array)
ActiveRecord::Base.cache do
entries.each do |entry|
entry.update_costs!(rate)
end
end
end
end
def after_create(rate)
o = Methods.new(rate)
next_rate = rate.next
# and entries from all projects that need updating
entries = o.orphaned_child_entries(rate.valid_from, (next_rate.valid_from if next_rate))
o.update_entries(entries)
end
def after_update(rate)
# FIXME: This might be extremly slow. Consider using an implementation like in HourlyRateObserver
unless rate.valid_from_changed?
# We have not moved a rate, maybe just changed the rate value
return unless rate.rate_changed?
# Only the rate value was changed so just update the currently assigned entries
return after_create(rate)
end
after_destroy(rate)
after_create(rate)
end
def after_destroy(rate)
o = Methods.new(rate)
o.update_entries(TimeEntry.find(:all, :conditions => {:rate_id => rate.id}))
end
end

@ -0,0 +1,80 @@
require_dependency "time_entry"
require_dependency "cost_entry"
module Entry
[TimeEntry, CostEntry].each { |e| e.send :include, self }
class Delegator < ActiveRecord::Base
self.abstract_class = true
class << self
def ===(obj)
TimeEntry === obj or CostEntry === obj
end
def calculate(type, *args)
a, b = TimeEntry.calculate(type, *args), CostEntry.calculate(type, *args)
case type
when :sum, :count then a + b
when :avg then (a + b) / 2
when :min then [a, b].min
when :max then [a, b].max
else raise NotImplementedError
end
end
%[find_by_sql count_by_sql count sum].each do |meth|
define_method(meth) { |*args| find_all(meth, *args) }
end
undef_method :create, :update, :delete, :destroy, :new, :update_counters,
:increment_counter, :decrement_counter
%w[update_all destroy_all delete_all].each do |meth|
define_method(meth) { |*args| send_all(meth, *args) }
end
private
def find_initial(options) find_one :find_initial, options end
def find_last(options) find_one :find_last, options end
def find_every(options) find_many :find_every, options end
def find_from_ids(args, options) find_many :find_from_ids, options end
def find_one(*args)
TimeEntry.send(*args) || CostEntry.send(*args)
end
def find_many(*args)
TimeEntry.send(*args) + CostEntry.send(*args)
end
def send_all(*args)
[TimeEntry.send(*args), CostEntry.send(*args)]
end
end
end
def units
super
rescue NoMethodError
hours
end
def cost_type
super
rescue NoMethodError
end
def activity
super
rescue NoMethodError
end
def activity_id
super
rescue NoMethodError
end
def self.method_missing(*a, &b)
Delegator.send(*a, &b)
end
end

@ -0,0 +1,6 @@
class FixedCostObject < CostObject
# Label of the current type for display in GUI.
def type_label
return l(:label_fixed_cost_object)
end
end

@ -0,0 +1,47 @@
class HourlyRate < Rate
belongs_to :user
belongs_to :project
validates_uniqueness_of :valid_from, :scope => [:user_id, :project_id]
validates_presence_of :user_id, :project_id, :valid_from
def validate
# Only allow change of project and user on first creation
return if self.new_record?
errors.add :project_id, :activerecord_error_invalid if project_id_changed?
errors.add :user_id, :activerecord_error_invalid if user_id_changed?
end
def previous(reference_date = self.valid_from)
# This might return a default rate
self.user.rate_at(reference_date - 1, self.project)
end
def next(reference_date = self.valid_from)
HourlyRate.find(
:first,
:conditions => [ "user_id = ? and project_id = ? and valid_from > ?",
self.user_id, self.project_id, reference_date],
:order => "valid_from ASC"
)
end
def self.history_for_user(usr, check_permissions = true)
rates = Hash.new
Projects.has_module(:costs_plugin).active.visible.each do |project|
next if (check_permissions && !User.current.allowed_to?(:view_hourly_rates, project, {:for_user => usr}))
rates[project] = HourlyRate.find(:all,
:conditions => { :user_id => usr, :project_id => project },
:order => "#{HourlyRate.table_name}.valid_from desc")
end
# FIXME: What permissions to apply here?
rates[nil] = DefaultHourlyRate.find(:all,
:conditions => { :user_id => usr},
:order => "#{DefaultHourlyRate.table_name}.valid_from desc")
rates
end
end

@ -0,0 +1,23 @@
class LaborBudgetItem < ActiveRecord::Base
belongs_to :cost_object
belongs_to :user
validates_length_of :comments, :maximum => 255, :allow_nil => true
validates_presence_of :user
def costs
self.budget || self.calculated_costs
end
def calculated_costs(fixed_date = cost_object.fixed_date, project_id = cost_object.project_id)
if user && hours && rate = user.rate_at(fixed_date, project_id)
rate.rate * hours
else
0.0
end
end
def can_view_costs?(usr, project)
usr.allowed_to?(:view_all_rates, project) || (user && usr == user && user.allowed_to?(:view_own_rate, project))
end
end

@ -0,0 +1,19 @@
class MaterialBudgetItem < ActiveRecord::Base
belongs_to :cost_object
belongs_to :cost_type
validates_length_of :comments, :maximum => 255, :allow_nil => true
validates_presence_of :cost_type
def costs
self.budget || self.calculated_costs
end
def calculated_costs(fixed_date = cost_object.fixed_date)
if units && cost_type && rate = cost_type.rate_at(fixed_date)
rate.rate * units
else
0.0
end
end
end

@ -0,0 +1,24 @@
class Rate < ActiveRecord::Base
validates_numericality_of :rate, :allow_nil => false, :message => :activerecord_error_invalid
def self.clean_currency(value)
if value && value.is_a?(String)
value = value.strip
value.gsub!(l(:currency_delimiter), '') if value.include?(l(:currency_delimiter)) && value.include?(l(:currency_separator))
value.gsub(',', '.')
else
value
end
end
def validate
valid_from.to_date
rescue Exception
errors.add :valid_from, :activerecord_error_invalid
end
def before_save
self.valid_from &&= valid_from.to_date
end
end

@ -0,0 +1,177 @@
class RateObserver < ActiveRecord::Observer
observe :hourly_rate, :cost_rate
class Methods
def initialize(changed_rate)
@rate = changed_rate
end
# order the dates
def order_dates(*dates)
dates.compact!
dates.size == 1 ? dates.first : dates.sort
end
def conditions_after(date, date_column = :spent_on)
if @rate.is_a?(HourlyRate)
[
"#{date_column} >= ? AND user_id = ? and project_id = ?",
date, @rate.user_id, @rate.project_id
]
else
[
"#{date_column} >= ? AND cost_type_id = ?",
date, @rate.cost_type_id
]
end
end
def conditions_between(date1, date2 = nil, date_column = :spent_on)
# if the second date is not given, return all entries
# with a spent_on after the given date
return conditions_after(date1 || date2, date_column) if date1.nil? || date2.nil?
(date1, date2) = order_dates(date1, date2)
# return conditions for all entries between date1 and date2 - 1 day
if @rate.is_a?(HourlyRate)
{ date_column => date1..(date2 - 1),
:user_id => @rate.user_id,
:project_id => @rate.project_id
}
else
{ date_column => date1..(date2 - 1),
:cost_type_id => @rate.cost_type_id,
}
end
end
def find_entries(date1, date2 = nil)
if @rate.is_a?(HourlyRate)
TimeEntry.find(:all, :conditions => conditions_between(date1, date2), :include => :rate)
else
CostEntry.find(:all, :conditions => conditions_between(date1, date2), :include => :rate)
end
end
def update_entries(entries, rate = @rate)
# This methods updates the given array of time or cost entries with the given rate
entries = [entries] unless entries.is_a?(Array)
ActiveRecord::Base.cache do
entries.each do |entry|
entry.update_costs!(rate)
end
end
end
def count_rates(date1, date2 = nil)
(@rate.class).count(:conditions => conditions_between(date1, date2, :valid_from))
end
def orphaned_child_entries(date1, date2 = nil)
# This method returns all entries in child projects without an explicit
# rate or with a rate id of rate_id between date1 and date2
# i.e. the ones with an assigned default rate or without a rate
return [] unless @rate.is_a?(HourlyRate)
(date1, date2) = order_dates(date1, date2)
# This gets an array of all the ids of the DefaultHourlyRates
default_rates = DefaultHourlyRate.find(:all, :select => :id).inject([]){|r,d|r<<d.id}
if date1.nil? || date2.nil?
# we have only one date, query >=
conditions = [
"user_id = ? AND project_id IN (?) AND (rate_id IN (?) OR rate_id IS NULL) AND spent_on >= ?",
@rate.user_id, @rate.project.descendants, default_rates, date1 || date2
]
else
# we have two dates, query between
conditions = [
"user_id = ? AND project_id IN (?) AND (rate_id IN (?) OR rate_id IS NULL) AND spent_on BETWEEN ? AND ?",
@rate.user_id, @rate.project.descendants, default_rates, date1, date2
]
end
TimeEntry.find(:all, :conditions => conditions, :include => :rate)
end
def child_entries(date1, date2 = nil)
# This method returns all entries in child projects without an explicit
# rate or with a rate id of rate_id between date1 and date2
# i.e. the ones with an assigned default rate or without a rate
return [] unless @rate.is_a?(HourlyRate)
(date1, date2) = order_dates(date1, date2)
if date1.nil? || date2.nil?
# we have only one date, query >=
conditions = [
"user_id = ? AND project_id IN (?) AND rate_id = ? AND spent_on >= ?",
@rate.user_id, @rate.project.descendants, @rate.id, date1 || date2
]
else
# we have two dates, query between
conditions = [
"user_id = ? AND project_id IN (?) AND rate_id = ? AND spent_on BETWEEN ? AND ?",
@rate.user_id, @rate.project.descendants, @rate.id, date1, date2
]
end
TimeEntry.find(:all, :conditions => conditions, :include => :rate)
end
end
def after_create(rate)
o = Methods.new(rate)
next_rate = rate.next
# get entries from the current project
entries = o.find_entries(rate.valid_from, (next_rate.valid_from if next_rate))
# and entries from subprojects that need updating (only applies to hourly_rates)
entries += o.orphaned_child_entries(rate.valid_from, (next_rate.valid_from if next_rate))
o.update_entries(entries)
end
def after_update(rate)
o = Methods.new(rate)
unless rate.valid_from_changed?
# We have not moved a rate, maybe just changed the rate value
return unless rate.rate_changed?
# Only the rate value was changed so just update the currently assigned entries
return after_create(rate)
end
# We have definitely moved the rate
if o.count_rates(rate.valid_from_was, rate.valid_from) > 0
# We have passed the boundary of another rate
# We do essantially the same as deleting the old rate and adding a new one
# So first assign all entries from the old a new rate
after_destroy(rate)
# Now update the newly assigned entries
after_create(rate)
else
# We have only moved the rate without passing other rates
# So we have to either assign some entries to our previous rate (if moved forwards)
# or assign some entries to self (if moved backwards)
# get entries from the current project
entries = o.find_entries(rate.valid_from_was, rate.valid_from)
# and entries from subprojects that need updating (only applies to hourly_rates)
entries += o.child_entries(rate.valid_from_was, rate.valid_from)
o.update_entries(entries, (rate.valid_from_was < rate.valid_from) ? rate.previous : rate)
end
end
def after_destroy(rate)
entry_class = rate.is_a?(HourlyRate) ? TimeEntry : CostEntry
entry_class.find(:all, :conditions => {:rate_id => rate.id}).each{|e| e.update_costs!}
end
end

@ -0,0 +1,96 @@
class VariableCostObject < CostObject
has_many :material_budget_items, :include => :cost_type, :foreign_key => 'cost_object_id', :dependent => :destroy
has_many :labor_budget_items, :include => :user, :foreign_key => 'cost_object_id', :dependent => :destroy
validates_associated :material_budget_items
validates_associated :labor_budget_items
after_update :save_material_budget_items
after_update :save_labor_budget_items
def copy_from(arg)
cost_object = arg.is_a?(VariableCostObject) ? arg : VariableCostObject.find(arg)
self.attributes = cost_object.attributes.dup
self.material_budget_items = cost_object.material_budget_items.collect {|v| v.clone}
self.labor_budget_items = cost_object.labor_budget_items.collect {|v| v.clone}
end
# Label of the current cost_object type for display in GUI.
def type_label
return l(:label_variable_cost_object)
end
def material_budget
material_budget_items.inject(0.0) {|sum, d| d.costs + sum}
end
def labor_budget
labor_budget_items.inject(0.0) {|sum,d| sum + d.costs}
end
def spent
spent_material + spent_labor
end
def spent_material
return @spent_material if @spent_material
return 0 unless issues.size > 0
@spent_material = issues.collect(&:material_costs).compact.sum
end
def spent_labor
return @spent_labor if @spent_labor
return 0 unless issues.size > 0
@spent_labor = issues.collect(&:labor_costs).compact.sum
end
def new_material_budget_item_attributes=(material_budget_item_attributes)
material_budget_item_attributes.each do |index, attributes|
material_budget_items.build(attributes) if attributes[:units].to_i > 0
end
end
def existing_material_budget_item_attributes=(material_budget_item_attributes)
material_budget_items.reject(&:new_record?).each do |material_budget_item|
attributes = material_budget_item_attributes[material_budget_item.id.to_s]
if attributes && attributes[:units].to_i > 0
attributes[:budget] = Rate.clean_currency(attributes[:budget])
material_budget_item.attributes = attributes
else
material_budget_items.delete(material_budget_item)
end
end
end
def save_material_budget_items
material_budget_items.each do |material_budget_item|
material_budget_item.save(false)
end
end
def new_labor_budget_item_attributes=(labor_budget_item_attributes)
labor_budget_item_attributes.each do |index, attributes|
labor_budget_items.build(attributes) if attributes[:hours].to_i > 0 && attributes[:user_id].to_i > 0
end
end
def existing_labor_budget_item_attributes=(labor_budget_item_attributes)
labor_budget_items.reject(&:new_record?).each do |labor_budget_item|
attributes = labor_budget_item_attributes[labor_budget_item.id.to_s]
attributes[:budget] = Rate.clean_currency(attributes[:budget])
if attributes && attributes[:hours].to_i > 0 && attributes[:user_id].to_i > 0
labor_budget_item.attributes = attributes
else
labor_budget_items.delete(labor_budget_item)
end
end
end
def save_labor_budget_items
labor_budget_items.each do |labor_budget_item|
labor_budget_item.save(false)
end
end
end

@ -0,0 +1 @@
<%= costs > 0 ? costs : "-" %>

@ -0,0 +1,31 @@
<% labelled_tabular_form_for :cost_object, @cost_object,
:url => {:action => 'edit', :id => @cost_object},
:html => {:multipart => true,
:id => 'cost_object_form',
:class => nil} do |f| %>
<%= error_messages_for 'cost_object' %>
<div class="box">
<div class="tabular">
<%= render :partial => 'form', :locals => {:f => f} %>
</div>
<fieldset><legend><%= l(:label_attachment_plural )%></legend>
<p><%= render :partial => 'attachments/form' %></p>
</fieldset>
</div>
<%= submit_tag l(:button_submit) %>
<%= link_to_remote l(:label_preview),
{ :url => { :controller => 'cost_objects', :action => 'preview', :project_id => @project },
:method => 'post',
:update => 'preview',
:with => "Form.serialize('cost_object_form')",
:complete => "Element.scrollTo('preview')"
}, :accesskey => accesskey(:preview) %>
<% end %>
<div id="preview" class="wiki"></div>
<% content_for :header_tags do %>
<%= stylesheet_link_tag 'scm' %>
<%= stylesheet_link_tag 'costs', :plugin => 'redmine_costs' %>
<%= javascript_include_tag 'cost_objects', :plugin => 'redmine_costs' %>
<% end %>

@ -0,0 +1,95 @@
<%= f.hidden_field :kind %>
<%# if @cost_object.new_record? %>
<% if false %>
<p><%= f.select :kind, [[l(:label_variable_cost_object), VariableCostObject.name], [l(:label_fixed_cost_object), FixedCostObject.name]], {:required => true, :prompt => true }, :autocomplete => 'off' %></p>
<%= observe_field :cost_object_kind, :url => { :action => :new },
:update => :content,
:with => "Form.serialize('cost_object_form')",
:before => "confirmChangeType('#{l(:text_cost_object_change_type_confirmation)}', $('cost_object_kind'), '#{@cost_object.kind.to_s}')" %>
<hr />
<% end %>
<p><%= f.text_field :subject, :size => 80, :required => true %></p>
<p><%= f.text_area :description,
:cols => 60,
:rows => (@cost_object.description.blank? ? 10 : [[10, @cost_object.description.length / 50].max, 100].min),
:accesskey => accesskey(:edit),
:class => 'wiki-edit' %></p>
<% if @cost_object.kind == "VariableCostObject" -%>
<script type="text/javascript">
//<![CDATA[
materialBudgetItemsForm = new Subform('<%= escape_javascript(render(:partial => "material_budget_item", :object => @cost_object.material_budget_items.build(:cost_type => CostType.default) )) %>',<%= @cost_object.material_budget_items.length %>,'material_budget_items_body');
laborBudgetItemsForm = new Subform('<%= escape_javascript(render(:partial => "labor_budget_item", :object => @cost_object.labor_budget_items.build )) %>',<%= @cost_object.labor_budget_items.length %>,'labor_budget_items_body');
//]]>
</script>
<fieldset>
<legend><%= l(:caption_material_budget) %></legend>
<table class="list material_budget_items" id="material_budget_items">
<thead><tr>
<th class="cost_units"><%= l(:caption_cost_unit_plural)%></th>
<th><%= l(:caption_cost_type) %></th>
<th><%= l(:label_comment) %></th>
<% if User.current.allowed_to?(:view_unit_price, @project)%><th class="currency" id="material_budget_items_price"><%= l(:caption_budget) %></th><%end%>
<th></th>
</tr></thead>
<tbody id="material_budget_items_body">
<%- @cost_object.material_budget_items.each_with_index do |material_budget_item, index| -%>
<%= render :partial => 'material_budget_item', :object => material_budget_item, :locals => {:index => index, :classes => cycle('odd', 'even')} %>
<%- end -%>
</tbody>
</table>
<div style="text-align: right"><%= link_to_function l(:button_add_budget_item), "materialBudgetItemsForm.add()", {:class => "icon icon-add"} %></div>
</fieldset>
<fieldset>
<legend><%= l(:caption_labor_budget) %></legend>
<table class="list labor_budget_items" id="labor_budget_items">
<thead><tr>
<th class="cost_units"><%= l(:field_hours)%></th>
<th><%= l(:label_user) %></th>
<th><%= l(:label_comment) %></th>
<% if User.current.allowed_to?(:view_all_rates, @project) || User.current.allowed_to?(:view_own_rate, @project) %>
<th class="currency" id="labor_budget_items_price"><%= l(:caption_budget) %></th>
<% end %>
<th></th>
</tr></thead>
<tbody id="labor_budget_items_body">
<%- @cost_object.labor_budget_items.each_with_index do |labor_budget_item, index| -%>
<%= render :partial => 'labor_budget_item', :object => labor_budget_item, :locals => {:index => index, :classes => cycle('odd', 'even')} %>
<%- end -%>
</tbody>
</table>
<div style="text-align: right"><%= link_to_function l(:button_add_budget_item), "laborBudgetItemsForm.add()", {:class => "icon icon-add"} %></div>
</fieldset>
<%- end %>
<div style="clear: both;"> </div>
<div class="splitcontentleft">
<p><%= f.check_box(:project_manager_signoff) %></p>
<p><%= f.check_box(:client_signoff) %></p>
</div>
<div class="splitcontentright">
<p><%= f.text_field :fixed_date, :size => 10 %><%= calendar_for('cost_object_fixed_date') %></p>
</div>
<div style="clear: both;"> </div>
<% if @cost_object.new_record? %>
<p><label><%=l(:label_attachment_plural)%></label><%= render :partial => 'attachments/form' %></p>
<% end %>
<%= wikitoolbar_for 'cost_object_description' %>
<% content_for :header_tags do %>
<%= javascript_include_tag 'cost_objects', :plugin => 'redmine_costs' %>
<%= javascript_include_tag 'subform', :plugin => 'redmine_costs' %>
<%= javascript_include_tag 'editinplace', :plugin => 'redmine_costs' %>
<%= javascript_tag "initialize_editinplace('src=\"#{image_path('cancel.png')}\" value=\"#{l(:label_cancel)}\"' )" %>
<% end %>

@ -0,0 +1,41 @@
<%-
index ||= "INDEX"
new_or_existing = labor_budget_item.new_record? ? 'new' : 'existing'
id_or_index = labor_budget_item.new_record? ? index : labor_budget_item.id
prefix = "cost_object[#{new_or_existing}_labor_budget_item_attributes][]"
id_prefix = "cost_object_#{new_or_existing}_labor_budget_item_attributes_#{id_or_index}"
name_prefix = "cost_object[#{new_or_existing}_labor_budget_item_attributes][#{id_or_index}]"
classes ||= ""
@labor_budget_item = labor_budget_item
error_messages = error_messages_for 'labor_budget_item'
-%>
<%if error_messages %><tr><td colspan="4"><%= error_messages %></td></tr><% end %>
<% fields_for prefix, labor_budget_item do |cost_form| %>
<tr class="cost_entry <%= classes %>" id="<%= id_prefix %>">
<td class="units">
<%= cost_form.text_field :hours, :index => id_or_index, :size => 3 %>
</td>
<td class="user">
<%= cost_form.select :user_id, @project.assignable_users.sort.collect{|u| [u.name, u.id]}, {:prompt => true}, {:index => id_or_index} %>
</td>
<td class="comment">
<%= cost_form.text_field :comments, :index => id_or_index, :size => 40 %>
</td>
<td class="currency">
<span id="<%= "#{id_prefix}_costs" %>" class="icon icon-edit" title="<%= l(:help_click_to_edit) %>">
<%= number_to_currency(labor_budget_item.calculated_costs(@cost_object.fixed_date, @cost_object.project_id)) if labor_budget_item.can_view_costs?(User.current, @project) %>
</span>
<%= update_page_tag do |page|
page << "makeEditable('#{id_prefix}_costs', '#{name_prefix}[budget]');"
page << "edit($('#{id_prefix}_costs'), '#{name_prefix}[budget]', '#{number_to_currency(labor_budget_item.budget)}');" if labor_budget_item.budget
end %>
<%= observe_field( "#{id_prefix}_user_id", :url => {:action => :update_labor_budget_item}, :with => "'user_id=' + encodeURIComponent(value) + '&hours=' + encodeURIComponent(document.getElementById('#{id_prefix}_hours').value) + '&fixed_date=' + encodeURIComponent(document.getElementById('cost_object_fixed_date').value) + '&element_id=#{id_prefix}'") %>
<%= observe_field( "#{id_prefix}_hours", :frequency => 1, :url => {:action => :update_labor_budget_item}, :with => "'user_id=' + encodeURIComponent(document.getElementById('#{id_prefix}_user_id').value) + '&hours=' + encodeURIComponent(value) + '&fixed_date=' + encodeURIComponent(document.getElementById('cost_object_fixed_date').value) + '&element_id=#{id_prefix}'") %>
</td>
<td class="delete" rowspan="2">
<%= link_to_function image_tag('delete.png'), "deleteLaborBudgetItem('#{id_prefix}')" %>
</td>
</tr>
<% end %>

@ -0,0 +1,53 @@
<% form_tag({}) do -%>
<table class="list cost_objects">
<thead><tr>
<th><%= link_to image_tag('toggle_check.png'), {}, :onclick => 'toggleIssuesSelection(Element.up(this, "form")); return false;',
:title => "#{l(:button_check_all)}/#{l(:button_uncheck_all)}" %>
</th>
<%= sort_header_tag("id", :caption => '#', :default_order => 'desc') %>
<%= sort_header_tag("status", :caption => l(:caption_status), :default_order => 'desc') %>
<%= sort_header_tag("subject", :caption => l(:caption_subject)) %>
<%= sort_header_tag("fixed_date", :caption => l(:caption_fixed_date)) %>
<th><%= l(:caption_spent) %></th>
<th><%= l(:caption_budget) %></th>
<th><%= l(:caption_labor_budget) %></th>
<th><%= l(:caption_material_budget) %></th>
<th><%= l(:caption_budget_ratio) %></th>
</tr></thead>
<tbody>
<% total_budget = 0; labor_budget = 0; material_budget = 0; spent = 0 %>
<% cost_objects.each do |cost_object| %>
<tr id="cost_object-<%= cost_object.id %>" class="hascontextmenu <%= cycle('odd', 'even') %> <%= cost_object.css_classes %>">
<td class="checkbox"><%= check_box_tag("ids[]", cost_object.id, false, :id => nil) %></td>
<td><%= link_to cost_object.id, :controller => 'cost_objects', :action => 'show', :id => cost_object %></td>
<%= content_tag(:td, l(cost_object.status), :class => 'status') %>
<%= content_tag(:td, link_to(h(cost_object.subject), :controller => 'cost_objects', :action => 'show', :id => cost_object), :class => 'subject') %>
<%= content_tag(:td, format_date(cost_object.fixed_date), :class => 'fixed_date') %>
<%= content_tag(:td, number_to_currency(cost_object.spent, :precision => 0), :class => 'currency') %>
<%= content_tag(:td, number_to_currency(cost_object.budget || 0.0, :precision => 0), :class => 'currency') %>
<%= content_tag(:td, number_to_currency(cost_object.labor_budget || 0.0, :precision => 0), :class => 'currency') %>
<%= content_tag(:td, number_to_currency(cost_object.material_budget || 0.0, :precision => 0), :class => 'currency') %>
<%= content_tag(:td, extended_progress_bar(cost_object.budget_ratio, :width => '100%')) %>
<%-
total_budget += cost_object.budget
labor_budget += cost_object.labor_budget
material_budget += cost_object.material_budget
spent += cost_object.spent
-%>
</tr>
<% end %>
<% if cost_objects.length > 0 %>
<tr>
<td colspan="5" />
<td class="currency"><strong><%= number_to_currency( spent || 0.0, :precision => 0) %></strong></td>
<td class="currency"><strong><%= number_to_currency( total_budget || 0.0, :precision => 0) %></strong></td>
<td class="currency"><strong><%= number_to_currency( labor_budget || 0.0, :precision => 0) %></strong></td>
<td class="currency"><strong><%= number_to_currency( material_budget || 0.0, :precision => 0) %></strong></td>
<td />
<tr>
<% end %>
</tbody>
</table>
<% end -%>

@ -0,0 +1,45 @@
<%-
index ||= "INDEX"
new_or_existing = material_budget_item.new_record? ? 'new' : 'existing'
id_or_index = material_budget_item.new_record? ? index : material_budget_item.id
prefix = "cost_object[#{new_or_existing}_material_budget_item_attributes][]"
id_prefix = "cost_object_#{new_or_existing}_material_budget_item_attributes_#{id_or_index}"
name_prefix = "cost_object[#{new_or_existing}_material_budget_item_attributes][#{id_or_index}]"
classes ||= ""
@material_budget_item = material_budget_item
error_messages = error_messages_for 'material_budget_item'
-%>
<% unless error_messages.blank? %><tr><td colspan="4"><%= error_messages %></td></tr><% end %>
<% fields_for prefix, material_budget_item do |cost_form| %>
<tr class="cost_entry <%= classes %>" id="<%= id_prefix %>">
<td class="units">
<%= cost_form.text_field :units, :index => id_or_index, :size => 3 %>
<span id="<%= "#{id_prefix}_unit_name" %>">
<%=h material_budget_item.cost_type.unit_plural if material_budget_item.cost_type %>
</span>
</td>
<td class="cost_type">
<%= cost_form.select :cost_type_id, cost_types_collection_for_select_options(material_budget_item.cost_type), {}, {:index => id_or_index} %>
<%= observe_field( "#{id_prefix}_cost_type_id", :url => {:action => :update_material_budget_item}, :with => "'cost_type_id=' + encodeURIComponent(value) + '&units=' + encodeURIComponent(document.getElementById('#{id_prefix}_units').value) + '&fixed_date=' + encodeURIComponent(document.getElementById('cost_object_fixed_date').value) + '&element_id=#{id_prefix}'") %>
<%= observe_field( "#{id_prefix}_units", :frequency => 1, :url => {:action => :update_material_budget_item}, :with => "'cost_type_id=' + encodeURIComponent(document.getElementById('#{id_prefix}_cost_type_id').value) + '&units=' + encodeURIComponent(value) + '&fixed_date=' + encodeURIComponent(document.getElementById('cost_object_fixed_date').value) + '&element_id=#{id_prefix}'") %>
</td>
<td class="comment">
<%= cost_form.text_field :comments, :index => id_or_index, :size => 40 %>
</td>
<td class="currency">
<span id="<%= "#{id_prefix}_costs" %>" class="icon icon-edit" title="<%= l(:help_click_to_edit) %>">
<%= number_to_currency(material_budget_item.calculated_costs(@cost_object.fixed_date)) %>
</span>
<%= update_page_tag do |page|
page << "makeEditable('#{id_prefix}_costs', '#{name_prefix}[budget]');"
page << "edit($('#{id_prefix}_costs'), '#{name_prefix}[budget]', '#{number_to_currency(material_budget_item.budget)}');" if material_budget_item.budget
end %>
</td>
<td class="delete">
<%= link_to_function image_tag('delete.png'), "deleteMaterialBudgetItem('#{id_prefix}')" %>
</td>
</tr>
<% end %>

@ -0,0 +1,130 @@
<h3><%= l(:caption_materials) %></h3>
<div style="float: left; width: 100%">
<div class="splitcontentleft">
<h4><%= l(:caption_material_budget)%></h4>
<table class="material_budget_items">
<thead><tr>
<th><%= l(:caption_cost_unit_plural)%></th>
<th><%= l(:caption_cost_type) %></th>
<th><%= l(:caption_comment) %></th>
<th><%= l(:caption_budget) %></th>
</tr></thead>
<tbody>
<% @cost_object.material_budget_items.each do |material_budget_item| %>
<tr>
<td class="units"><%=h pluralize(material_budget_item.units, material_budget_item.cost_type.unit, material_budget_item.cost_type.unit_plural) %></td>
<td><%=h material_budget_item.cost_type.name %></td>
<td class="comments"><%=h material_budget_item.comments %></td>
<td class="currency"><%= number_to_currency(material_budget_item.costs) %></td>
</tr>
<% end %>
<tr><td colspan="4" class="currency"><strong><%= number_to_currency(@cost_object.material_budget) %></strong></td></tr>
</tbody>
</table>
</div>
<div class="splitcontentright">
<h4><%= l(:caption_material_costs) %></h4>
<table class="material_budget_items">
<thead><tr>
<th><%= l(:caption_issue)%></th>
<th><%= l(:caption_cost_unit_plural) %></th>
<th><%= l(:caption_cost_type) %></th>
<th><%= l(:caption_costs) %></th>
</tr></thead>
<tbody>
<% @cost_object.issues.each do |issue|
cost_entries = issue.cost_entries.inject(Hash.new) do |results, entry|
result = results[entry.cost_type.id.to_s]
unless result
result = CostEntry.new(:cost_type => entry.cost_type, :cost_object => @cost_object, :overridden_costs => 0.0, :units => 0)
results[entry.cost_type.id.to_s] = result
end
result.overridden_costs += entry.real_costs
result.units += entry.units
results
end.values
cost_entries.each do |c|
%>
<tr>
<td class="subject"><%= link_to_issue issue %>: <%= h(truncate(issue.subject, 50)) -%></td>
<td><%= link_to pluralize(c.units, c.cost_type.unit, c.cost_type.unit_plural), {:controller => "costlog", :action => "details", :cost_type_id => c.cost_type, :issue_id => issue} %></td>
<td><%= c.cost_type %></td>
<td class="currency"><%= number_to_currency(c.real_costs) %></td>
</tr>
<% end %>
<% end %>
<tr><td colspan="4" class="currency"><strong><%= number_to_currency(@cost_object.spent_material) %></strong></td></tr>
</tbody>
</table>
</div>
</div>
<h3 style="clear:left;"><%= l(:caption_labor) %></h3>
<div class="splitcontentleft">
<h4><%= l(:caption_labor_budget)%></h4>
<table class="labor_budget_items">
<thead><tr>
<th><%= l(:field_hours)%></th>
<th><%= l(:label_user) %></th>
<th><%= l(:caption_comment) %></th>
<th><%= l(:caption_budget) %></th>
</tr></thead>
<tbody>
<% @cost_object.labor_budget_items.each do |labor_budget_item| %>
<tr>
<td class="hours"><%= labor_budget_item.hours %>h</td>
<td><%=h labor_budget_item.user.name %></td>
<td class="comments"><%=h labor_budget_item.comments %></td>
<td class="currency"><%= number_to_currency(labor_budget_item.costs) %></td>
</tr>
<% end %>
<% if User.current.allowed_to?(:view_all_rates, @project) %>
<tr><td colspan="4" class="currency"><strong><%= number_to_currency(@cost_object.labor_budget) %></strong></td></tr>
<% end %>
</tbody>
</table>
</div>
<div class="splitcontentright">
<h4><%= l(:caption_labor_costs) %></h4>
<table class="labor_budget_items">
<thead><tr>
<th><%= l(:caption_issue)%></th>
<th><%= l(:field_hours)%></th>
<th><%= l(:label_user) %></th>
<th><%= l(:caption_costs) %></th>
</tr></thead>
<tbody>
<% @cost_object.issues.each do |issue|
time_entries = issue.time_entries.inject(Hash.new) do |results, entry|
result = results[entry.user.id.to_s]
unless result
result = TimeEntry.new(:user => entry.user, :overridden_costs => 0, :hours => 0)
results[entry.user.id.to_s] = result
end
result.overridden_costs += entry.real_costs
result.hours += entry.hours
results
end.values
time_entries.each do |t|
%>
<tr>
<td class="subject"><%= link_to_issue issue %>: <%= h(truncate(issue.subject, 50)) -%></td>
<td class="hours"><%= link_to "#{t.hours}h", {:controller => "timelog", :action => "details", :issue_id => issue} %></td>
<td><%=h t.user.name %></td>
<td class="currency"><%= number_to_currency(t.real_costs) %></td>
</tr>
<% end %>
<% end %>
<tr><td colspan="4" class="currency"><strong><%= number_to_currency(@cost_object.spent_labor) %></strong></td></tr>
</tbody>
</table>
</div>
<div style="clear: both"></div>

@ -0,0 +1,4 @@
<h3><%= l(:label_cost_object_plural) %></h3>
<%= link_to_if_authorized l(:label_cost_object_new), {:action => 'new', :project_id => @project } %><br />
<%= link_to l(:label_view_all_cost_objects), {:action => 'index'} %><br />

@ -0,0 +1,4 @@
<h2><%= l(:label_cost_object_id @cost_object.id) %></h2>
<%= render :partial => 'edit' %>
<%= javascript_tag "Form.Element.focus('cost_object_subject');" %>

@ -0,0 +1,40 @@
<div class="contextual">
<%= link_to_if_authorized l(:button_add_cost_object), {:controller => 'cost_objects', :action => 'new', :project_id => @project }, :class => 'icon icon-add' %>
</div>
<h2><%=l(:label_cost_object_plural)%></h2>
<% html_title(l(:label_cost_object_plural)) %>
<% if @cost_objects.empty? %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% else %>
<%= render :partial => 'list', :locals => {:cost_objects => @cost_objects} %>
<p class="pagination"><%= pagination_links_full @cost_object_pages, @cost_object_count %></p>
<% end %>
<p class="other-formats">
<%= l(:label_export_to) %>
<!--
<span><%= link_to 'Atom', {:query_id => @query, :format => 'atom', :key => User.current.rss_key}, :class => 'feed' %></span>
-->
<span><%= link_to 'CSV', {:format => 'csv'}, :class => 'csv' %></span>
<!--
<span><%= link_to 'PDF', {:format => 'pdf'}, :class => 'pdf' %></span>
-->
</p>
<% content_for :sidebar do %>
<%= render :partial => 'cost_objects/sidebar' %>
<% end %>
<% content_for :header_tags do %>
<%= javascript_include_tag 'context_menu' %>
<%= stylesheet_link_tag 'context_menu' %>
<%= stylesheet_link_tag 'scm' %>
<%= stylesheet_link_tag 'costs', :plugin => 'redmine_costs' %>
<% end %>
<% html_title l(:label_cost_object_plural) %>
<div id="context-menu" style="display: none;"></div>
<%= javascript_tag "new ContextMenu('#{url_for(:controller => 'cost_objects', :action => 'context_menu')}')" %>

@ -0,0 +1,38 @@
<h2><%=l(:label_cost_object_new)%></h2>
<% labelled_tabular_form_for :cost_object, @cost_object,
:html => {:multipart => true, :id => 'cost_object_form'} do |f| %>
<%= error_messages_for 'cost_object' %>
<div class="box">
<%= render :partial => 'form', :locals => {:f => f} %>
</div>
<%= submit_tag l(:button_create) %>
<%= submit_tag l(:button_create_and_continue), :name => 'continue' %>
<%= link_to_remote l(:label_preview),
{ :url => { :controller => 'cost_objects', :action => 'preview', :project_id => @project },
:method => 'post',
:update => 'preview',
:with => "Form.serialize('cost_object_form')",
:complete => "Element.scrollTo('preview')"
}, :accesskey => accesskey(:preview) %>
<%= javascript_tag "Form.Element.focus('cost_object_subject');" %>
<% end %>
<div id="preview" class="wiki"></div>
<% content_for :sidebar do %>
<%= render :partial => 'cost_objects/sidebar' %>
<% end %>
<% content_for :header_tags do %>
<%= stylesheet_link_tag 'scm' %>
<%= stylesheet_link_tag 'costs', :plugin => 'redmine_costs' %>
<% end %>

@ -0,0 +1,68 @@
<div class="contextual">
<%= link_to_if_authorized l(:button_update), {:controller => 'cost_objects', :action => 'edit', :id => @cost_object}, :onclick => 'showAndScrollTo("update", "cost_object_description"); return false;', :class => 'icon icon-edit', :accesskey => accesskey(:edit) %>
<%= link_to_if_authorized l(:button_copy), {:controller => 'cost_objects', :action => 'new', :project_id => @project, :copy_from => @cost_object }, :class => 'icon icon-copy' %>
<%= link_to_if_authorized l(:button_delete), {:controller => 'cost_objects', :action => 'destroy', :id => @cost_object}, :confirm => l(:text_are_you_sure), :method => :post, :class => 'icon icon-del' %>
</div>
<h2><%= l(:label_cost_object_id, @cost_object.id) %></h2>
<div class="<%= @cost_object.css_classes %> details">
<h3><%=h @cost_object.subject %></h3>
<p class="author">
<%= authoring @cost_object.created_on, @cost_object.author %>.
<%= l(:label_updated_time, distance_of_time_in_words(Time.now, @cost_object.updated_on)) + '.' if @cost_object.created_on != @cost_object.updated_on %>
</p>
<table width="100%">
<tr>
<td style="width:15%" class="type"><strong><%=l(:field_type)%>:</strong></td>
<td style="width:35%" class="type"><%= @cost_object.type_label %></td>
<td style="width:15%" class="due-date"><strong><%=l(:field_fixed_date)%>:</strong></td>
<td style="width:35%"><%= format_date(@cost_object.fixed_date) %></td>
</tr>
<tr>
<td class="status"><strong><%=l(:field_status)%>:</strong></td>
<td class="status"><%= l(@cost_object.status) %></td>
<td class="progress"><strong><%=l(:field_budget_ratio)%>:</strong></td>
<td class="progress"><%= extended_progress_bar(@cost_object.budget_ratio, :width => '80px', :legend => "#{@cost_object.budget_ratio}%") %></td>
</tr>
</table>
<p><strong><%=l(:field_description)%></strong></p>
<div class="wiki">
<%= textilizable @cost_object, :description, :attachments => @cost_object.attachments %>
</div>
<%= link_to_attachments @cost_object %>
<%= render :partial => "show_#{@cost_object.kind.underscore}" %>
</div>
<div style="clear: both;"></div>
<% if authorize_for('cost_objects', 'edit') %>
<div id="update" style="display:none;">
<h3><%= l(:button_update) %></h3>
<%= render :partial => 'edit' %>
</div>
<% end %>
<p class="other-formats">
<%= l(:label_export_to) %>
<span><%= link_to 'PDF', {:format => 'pdf'}, :class => 'pdf' %></span>
</p>
<% html_title "#{l(:label_cost_object)} ##{@cost_object.id}: #{@cost_object.subject}" %>
<% content_for :sidebar do %>
<%= render :partial => 'cost_objects/sidebar' %>
<% end %>
<% content_for :header_tags do %>
<%= stylesheet_link_tag 'scm' %>
<%= stylesheet_link_tag 'costs', :plugin => 'redmine_costs' %>
<%= javascript_include_tag 'cost_objects', :plugin => 'redmine_costs' %>
<% end %>

@ -0,0 +1,36 @@
<%#
This partial requires the following locals:
filter The filter object to create a row for
The following locals are optional
index The id of the filter field
%>
<%
index ||= "---INDEX---"
prefix = "filters[]"
id_prefix = "filters_#{index}"
name_prefix = "filters[#{index}]"
%>
<% fields_for prefix, filter do |f| %>
<tr class="filter" id="<%= id_prefix %>">
<td style="width: 200px">
<%= f.hidden_field :scope, :index => index %>
<%= f.hidden_field :column_name, :index => index %>
<%= f.check_box :enabled, :index => index, :onclick => "toggle_filter($('#{id_prefix}'));" %>
<%= f.label :enabled, filter.label, :index => index, :class => scope_icon_class(filter) %>
</td>
<td style="width: 150px;">
<%= f.select :operator, operators_for_select(filter.type_name), {}, :index => index, :onchange => "toggle_operator($('#{id_prefix}'))", :class => "select-small filter_operator", :style => "vertical-align: top;" %>
</td>
<td>
<% simple_operator = CostQuery.operators[filter.operator] ? CostQuery.operators[filter.operator][:simple] : false -%>
<div class="filter_values" <%= 'style="display: none"' if simple_operator %>>
<%= render :partial => "cost_reports/filter_types/#{filter.type_name}", :locals => {:f => f, :filter => filter, :index => index, :id_prefix => id_prefix, :name_prefix => name_prefix} %>
</div>
</td>
</tr>
<% end %>

@ -0,0 +1,148 @@
<%
add_issue_filter_select = [["", ""]]
add_cost_filter_select = [["", ""]]
%>
<script type="text/javascript">
//<![CDATA[
var Filterform = Class.create({
lineIndex: 1,
parentElement: "",
initialize: function(Filters, lineIndex, parentElement) {
this.Filters = Filters;
this.lineIndex = lineIndex;
this.parentElement = parentElement;
},
parsedHTML: function(scope, key) {
var html = this.Filters.get(scope).get(key);
if (html) {
return html.replace(/---INDEX---/g, this.lineIndex++);
}
},
add: function(scope, key) {
var e = $(this.parentElement);
Element.insert(e, { bottom: this.parsedHTML(scope, key)});
return this.lineIndex-1;
},
add_after: function(e, scope, key) {
Element.insert(e, { after: this.parsedHTML(scope, key)});
return this.lineIndex-1;
},
add_on_top: function(scope, key) {
var e = $(this.parentElement);
Element.insert(e, { top: this.parsedHTML(scope, key)});
return this.lineIndex-1;
}
});
multiFilters = new Hash();
<% @query.available_filters.each_pair do |scope, available_filters| -%>
multiFilters.set('<%= scope %>', [<%= available_filters.select{|k,v| CostQuery.filter_types[v[:type]][:multiple]}.collect{|k,v| "'#{k}'"}.join(", ") %>]);
<%- end %>
function add_filter(select, scope) {
var column_name = select.value;
var filter_id = filterform.add(scope, column_name);
var new_filter = $('filters_' + filter_id);
if (!new_filter) { return; }
new_filter.down("input[type=checkbox]").checked = true;
toggle_filter(new_filter);
select.selectedIndex = 0;
if (!multiFilters.get(scope).include(column_name)) {
// the current filter can only be applied once
for (i=0; i<select.options.length; i++) {
var option = select.options[i];
if (option.value == column_name) {
option.disabled = true;
}
}
}
}
function toggle_filter(filter) {
var check_box = filter.down("input[type=checkbox]")
if (check_box.checked) {
filter.down("select.filter_operator").show();
toggle_operator(filter);
} else {
filter.down("select.filter_operator").hide();
filter.down("div.filter_values").hide();
}
}
function toggle_operator(filter) {
var operator = filter.down("select.filter_operator");
var simple_operators = [<%= CostQuery.operators.select{|k,v| v[:simple]}.collect{|k,v| "'#{k}'"}.join(", ") %>]
var values_field = filter.down("div.filter_values");
if (simple_operators.include(operator.value)) {
values_field.hide();
} else {
values_field.show();
}
}
function toggle_multi_select(filter) {
var select = filter.down("div.filter_values").down("select");
if (select.readAttribute("multiple")) {
select.writeAttribute("multiple", null);
} else {
select.writeAttribute("multiple", "multiple");
}
}
IssueFilterTypes = new Hash();
<%
@query.available_filters[:issues].sort_by{|c| c[1][:order]}.each do |e|
column_name = e[0]
filter = @query.create_filter(:issues, column_name)
add_issue_filter_select << [filter.label, column_name]
%>
IssueFilterTypes.set('<%= escape_javascript(column_name) %>', '<%= escape_javascript( render(:partial => "filter", :object => filter) ) %>');
<% end %>
CostEntryFilterTypes = new Hash()
<%
@query.available_filters[:costs].sort_by{|c| c[1][:order]}.each do |e|
column_name = e[0]
filter = @query.create_filter(:costs, column_name)
add_cost_filter_select << [filter.label, column_name]
%>
CostEntryFilterTypes.set('<%= escape_javascript(column_name) %>', '<%= escape_javascript( render(:partial => "filter", :object => filter) ) %>');
<% end %>
filterform = new Filterform($H({issues: IssueFilterTypes, costs: CostEntryFilterTypes}), <%= @query.filters ? @query.filters.length : 0 %>, 'filter_table');
//]]>
</script>
<table width="100%">
<tbody><tr>
<td><table id="filter_table">
<% @query.filters.each_with_index do |filter, index| %>
<%= render(:partial => "filter", :object => @query.create_filter_from_hash(filter), :locals => {:index => index}) %>
<% end if @query.filters %>
</table></td>
<td class="add-filter">
<%= l(:label_cost_filter_add) %>: <%= select_tag 'add_cost_filter_select', options_for_select(add_cost_filter_select),
:onchange => "add_filter(this, 'costs');",
:class => "select-small",
:name => nil %><br />
<%= l(:label_issue_filter_add) %>: <%= select_tag 'add_issue_filter_select', options_for_select(add_issue_filter_select),
:onchange => "add_filter(this, 'issues');",
:class => "select-small",
:name => nil %>
</td>
</tr></tbody>
</table>

@ -0,0 +1,18 @@
<script type="text/javascript">
//<![CDATA[
function group_by_changed()
{
$('group_by[granularity]').setStyle({
'display': <%= @query.time_groups.inject([]){|r,t| r<<t.to_s}.inspect %>.include($("group_by[name]").value) ? 'inline' : 'none'});
}
//]]>
</script>
<%=
select_tag "group_by[name]", options_for_select(@query.group_by_columns_for_select, (@query.group_by[:name].to_s unless @query.group_by[:name].blank?)),
:class => "select-small", :onchange => "group_by_changed();"
%>
<%=
select_tag("group_by[granularity]", options_for_select([[l(:label_year), "year"], [l(:label_month), "month"], [l(:label_week), "week"], [l(:label_day_plural), "day"]], @query.group_by[:granularity]),
:class => "select-small", :onload => "group_by_changed();", :style => ("display: none" if @query.group_by["name"].blank? || !@query.time_groups.include?(@query.group_by["name"].to_s)))
%>

@ -0,0 +1,5 @@
<% if @grouped_entries %>
<%= render :partial => "list_group_by" %>
<% else %>
<%= render :partial => "list_items" %>
<% end %>

@ -0,0 +1,94 @@
<%
def get_column
CostQuery.group_by_columns[@query.group_by[:name]]
end
def display_js(invert=false)
group_by_column = get_column
return "'' + Form.serialize('filter-options')" unless group_by_column[:scope] == :costs
display_costs = ((CostEntry.column_names.include? group_by_column[:db_field].to_s) && @query.display_cost_entries)
display_time = ((TimeEntry.column_names.include? group_by_column[:db_field].to_s) && @query.display_time_entries)
if invert
display_costs = !display_costs
display_time = !display_time
end
if display_costs && !display_time
"'cost_query[display_cost_entries]=1&cost_query[display_time_entries]=0'"
elsif !display_costs && display_time
"'cost_query[display_cost_entries]=0&cost_query[display_time_entries]=1'"
else
"Form.serialize('filter-options')"
end
end
def filter_js(filter_hash)
group_by_column = get_column
if group_by_column[:scope] == :costs && filter_hash[:values].nil?
return ""
end
{:filters => {(@query.filters ? @query.filters.length : 0) => filter_hash}}.to_query
end
%>
<table class="list">
<thead>
<th>Group By</th>
<th>Count</th>
<th>Sum</th>
<th>Drill Down</th>
</thead>
<tbody>
<%
@grouped_entries.each do |entry|
entry &&= entry.with_indifferent_access
filter = @query.filter_from_group_by(entry)
if filter[:values].nil?
display_js = display_js(true)
else
display_js = display_js(false)
end
filter_js = filter_js(filter)
group_by = {:group_by=>{:name=>"", :granularity=>"year"}}
fields = entry.keys - %w[count sum]
if fields.include? "tmonth"
name = "#{entry[:tyear]}, #{month_name(entry["tmonth"].to_i)}"
elsif fields.include? "tweek"
name = "#{entry[:tyear]}, #{l(:week)} \##{entry["tweek"]}"
else
name = fields.map { |k| CostQuery.get_name(k, entry[k]) }.join " "
end
name.strip!
%>
<tr>
<td>
<%= name %>
</td>
<td><%= entry["count"] %> Entries</td>
<td class="currency"><%= number_to_currency(entry["sum"]) %></td>
<td>
<%= link_to_remote "Drill Down", {
:url => { :set_filter => 1 },
:update => "content",
:with => "original_filters + '&#{filter_js}&#{group_by.to_query}&' + #{display_js}"
} %>
</td>
</tr>
<% end %>
<tr>
<td>&nbsp;</td>
<td><strong><%= @entry_count %> Entries</strong></td>
<td class="currency"><strong><%= number_to_currency @entry_sum %></strong></td>
<td>&nbsp;</td>
</tr>
</tbody>
</table>

@ -0,0 +1,61 @@
<% if @entries.blank? %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% else %>
<% page_costs = 0 %>
<table class="list cost_entries">
<thead><tr>
<th></th>
<%= sort_header_tag("entry__issue_id", :caption => l(:caption_issue), :default_order => 'desc') %>
<th><%= l(:caption_cost_unit_plural) %></th>
<%= sort_header_tag("entry__cost_type_id", :caption => l(:caption_cost_type), :default_order => 'asc') %>
<%= sort_header_tag("entry__activity_id", :caption => l(:label_activity), :default_order => 'asc') %>
<%= sort_header_tag("entry__spent_on", :caption => l(:caption_spent), :default_order => 'desc') %>
<%= sort_header_tag("entry__user_id", :caption => l(:label_user), :default_order => 'asc') %>
<%= sort_header_tag("entry__costs", :caption => l(:caption_costs), :default_order => 'asc') %>
<th></th>
</tr></thead>
<tbody>
<% @entries.each do |entry| %>
<% page_costs += entry.real_costs %>
<tr class="<%= cycle('odd', 'even') %>">
<td><span class="<%= entry.is_a?(CostEntry) ? "icon-money" : "icon-time" %> icon">&nbsp;</span></td>
<td class="subject"><%= link_to_issue entry.issue %></td>
<td>
<%= (link_to pluralize(entry.units, entry.cost_type.unit, entry.cost_type.unit_plural),
{:controller => "costlog", :action => "details",
:cost_type_id => entry.cost_type, :issue_id => entry.issue}) if entry.is_a?(CostEntry) %>
<%= entry.hours.to_s + "h" if entry.is_a?(TimeEntry)%>
</td>
<td><%= entry.is_a?(CostEntry) ? entry.cost_type : l(:caption_labor_costs) %></td>
<td><%= entry.activity if entry.is_a?(TimeEntry) %></td>
<td><%= format_date entry.spent_on %></td>
<td><%= link_to_user entry.user %></td>
<td class="currency"><%= number_to_currency(entry.real_costs) %></td>
<td style="width: 40px">
<% if entry.editable_by?(User.current) -%>
<% if entry.is_a? TimeEntry -%>
<%= link_to image_tag('edit.png'), {:controller => 'timelog', :action => 'edit', :id => entry, :project_id => nil},
:title => l(:button_edit) %>
<%= link_to image_tag('delete.png'), {:controller => 'timelog', :action => 'destroy', :id => entry, :project_id => nil},
:confirm => l(:text_are_you_sure),
:method => :post,
:title => l(:button_delete) %>
<% else %>
<%= link_to image_tag('edit.png'), {:controller => 'costlog', :action => 'edit', :id => entry, :project_id => nil},
:title => l(:button_edit) %>
<%= link_to image_tag('delete.png'), {:controller => 'costlog', :action => 'destroy', :id => entry, :project_id => nil},
:confirm => l(:text_are_you_sure),
:method => :post,
:title => l(:button_delete) %>
<% end -%>
<% end -%>
</td>
</tr>
<% end %>
</tbody>
</table>
<p class="pagination splitcontentleft"><%= pagination_links_full @entry_pages, @entry_count %></p>
<div class="currency" style="padding-right: 48px;"><%= l(:label_costs_per_page)%>: <%= number_to_currency page_costs %></div>
<div class="currency" style="clear: left;padding-right: 48px;"><strong><%=l :label_overall_costs %>: <%= number_to_currency @entry_sum %></strong></div>
<% end %>

@ -0,0 +1,5 @@
<p>
<%= l(:label_display_types) %>:
<%= query_form.check_box :display_cost_entries %> <%= query_form.label :display_cost_entries, l(:field_material_costs) %>
<%= query_form.check_box :display_time_entries %> <%= query_form.label :display_time_entries, l(:field_labor_costs) %>
</p>

@ -0,0 +1 @@
<%= f.text_field :values, :size => 3, :index => index, :class => "select-small" %> <%= l(:label_day_plural) %>

@ -0,0 +1,13 @@
<%= text_field_tag "#{name_prefix}[values][]", (filter.values.first if filter.values), :size => 10, :class => "select-small", :id => "#{id_prefix}_date1"%> <%= calendar_for("#{id_prefix}_date1") %>
<span id="<%= id_prefix %>_between_tags">
<%
between_tags =
l(:label_until) + " " +
text_field_tag("#{name_prefix}[values][]", (filter.values.last if filter.values), :size => 10, :class => "select-small", :id => "#{id_prefix}_date2") + " " +
calendar_for("#{id_prefix}_date2")
%>
<%= javascript_tag "#{id_prefix}_between_tags = '#{escape_javascript(between_tags)}';" %>
<%= observe_field "#{id_prefix}_operator", :function => "if (value == '<>d') {$('#{id_prefix}_between_tags').update(#{id_prefix}_between_tags);} else {$('#{id_prefix}_between_tags').update('');}" %>
<%= between_tags if filter.operator == '<>d' %>

@ -0,0 +1 @@
<%= render :partial => "cost_reports/filter_types/date", :locals => {:f => f, :filter => filter, :index => index, :id_prefix => id_prefix, :name_prefix => name_prefix} %>

@ -0,0 +1 @@
<%= f.text_field :values, :size => 3, :index => index, :class => "select-small" %>

@ -0,0 +1 @@
<%= render :partial => "cost_reports/filter_types/integer", :locals => {:f => f, :filter => filter, :index => index, :id_prefix => id_prefix, :name_prefix => name_prefix} %>

@ -0,0 +1,2 @@
<%= f.select :values, filter.available_values, {}, {:multiple => (filter.values && filter.values.length > 1), :index => "#{index}[]", :name => "#{name_prefix}[values][]" } %>
<%= link_to_function image_tag('bullet_toggle_plus.png'), "toggle_multi_select($('#{id_prefix}'));", :style => "vertical-align: bottom;" %>

@ -0,0 +1 @@
<%= render :partial => "cost_reports/filter_types/list", :locals => {:f => f, :filter => filter, :index => index, :id_prefix => id_prefix, :name_prefix => name_prefix} %>

@ -0,0 +1 @@
<%= render :partial => "cost_reports/filter_types/list", :locals => {:f => f, :filter => filter, :index => index, :id_prefix => id_prefix, :name_prefix => name_prefix} %>

@ -0,0 +1 @@
<%= render :partial => "cost_reports/filter_types/list", :locals => {:f => f, :filter => filter, :index => index, :id_prefix => id_prefix, :name_prefix => name_prefix} %>

@ -0,0 +1 @@
<%= f.text_field :values, :size => 30, :index => index, :class => "select-small" %>

@ -0,0 +1 @@
<%= render :partial => "cost_reports/filter_types/string", :locals => {:f => f, :filter => filter, :index => index, :id_prefix => id_prefix, :name_prefix => name_prefix} %>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save