git-svn-id: https://dev.finn.de/svn/cockpit/trunk@936 7926756e-e54e-46e6-9721-ed318f58905epull/6827/head
commit
824742112f
@ -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> </td> |
||||
<td><strong><%= @entry_count %> Entries</strong></td> |
||||
<td class="currency"><strong><%= number_to_currency @entry_sum %></strong></td> |
||||
<td> </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"> </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…
Reference in new issue