Time budgets per activity

Add a TimeBudget model which allows a job to have a time budget for each
activity type. We’re allowing nil as a holder for a generic budget,
though there’s currently no way to use that.

We automatically delete any time budgets which are set to zero.
This commit is contained in:
Trevor Vallender 2023-11-24 15:05:38 +00:00
parent c6cb6fa64d
commit 4b83753e71
9 changed files with 98 additions and 23 deletions

View File

@ -10,14 +10,15 @@ class JobsController < ApplicationController
end
def new
@job = Job.new
@job = Job.new.with_all_time_budgets
end
def edit
@job.with_all_time_budgets
end
def update
if @job.update(job_params)
if @job.update(remove_empty_time_budgets(job_params))
redirect_to job_path(@job, project_id: @job.project.id)
else
render :edit
@ -51,7 +52,8 @@ class JobsController < ApplicationController
:budget,
:external_project_id,
:name,
:description
:description,
time_budgets_attributes: [:id, :activity_id, :hours, :job_id, :_destroy]
)
end
@ -62,4 +64,12 @@ class JobsController < ApplicationController
def set_job
@job = Job.find(params[:id])
end
# If a time budget is set to 0, remove it
def remove_empty_time_budgets(params)
params[:time_budgets_attributes].each do |key, value|
params[:time_budgets_attributes][key]["_destroy"] = true if params[:time_budgets_attributes][key]["hours"] == "0"
end
params
end
end

View File

@ -7,10 +7,34 @@ class Job < ActiveRecord::Base
presence: true
belongs_to :project
has_many :time_entries
has_many :time_entries, dependent: :restrict_with_error
has_many :time_budgets, dependent: :restrict_with_error
accepts_nested_attributes_for :time_budgets, allow_destroy: true
scope :project, ->(project) { where(project_id: project.id) }
scope :project_or_parent, ->(project) { where(project_id: [project.id, project.parent.id]) }
scope :project_or_parent, ->(project) { where(project_id: [project.id, project.parent&.id]) }
def with_all_time_budgets
TimeEntryActivity.where.not(id: time_budgets.pluck(:activity_id)).each do |activity|
time_budgets << TimeBudget.new(job_id: id, activity_id: activity.id)
end
self
end
def missing_time_budgets
budgets = []
new_activities.collect { |activity| budgets << TimeBudget.new(job_id: id, activity_id: activity.id) }
end
def total_time_budget
return 0 if time_budgets.empty?
time_budgets.sum(&:hours)
end
def time_budget_for(activity)
time_budgets.where(activity_id: activity.id).hours || 0
end
def total_time_logged
TimeEntry.where(job_id: id)

11
app/models/time_budget.rb Normal file
View File

@ -0,0 +1,11 @@
class TimeBudget < ActiveRecord::Base
validates :hours,
presence: true
validates :activity_id,
inclusion: { in: TimeEntryActivity.pluck(:id) }
belongs_to :job
belongs_to :activity, class_name: "TimeEntryActivity"
end

View File

@ -0,0 +1,11 @@
<div class="label"><%= budget.activity.name %></div>
<div class="value">
<%= l_hours_short(
budget.job.total_time_logged_for(budget.activity)
) %> /
<%= l_hours_short(
budget.hours
) %>
<progress max="<%= budget %>"
value="<%= budget.job.total_time_logged_for(budget.activity) %>"></progress>
</div>

View File

@ -3,34 +3,36 @@
<p>
<%= f.label :name %>
<%= f.text_field :name %>
</p>
<p>
<%= f.label :description %>
<%= f.text_area :description %>
</p>
<p>
<%= f.label :starts_on %>
<%= f.date_field :starts_on %>
</p>
<p>
<%= f.label :ends_on %>
<%= f.date_field :ends_on %>
</p>
<%= f.hidden_field :project_id, value: @project.id %>
<p>
<%= f.label :external_project_id %>
<%= f.number_field :external_project_id %>
</p>
<p>
<%= f.label :budget %>
<%= f.number_field :budget %>
</p>
<fieldset>
<legend>Budget</legend>
<%= f.fields_for :time_budgets do |ff| %>
<p>
<%= ff.label :hours, ff.object.activity.name %>
<%= ff.number_field :hours %>
<%= ff.hidden_field :activity_id %>
<%= ff.hidden_field :job_id %>
<%= ff.hidden_field :_destroy, value: false %>
<% end %>
</fieldset>
</div>
<%= f.submit %>

View File

@ -1,9 +1,13 @@
<tr>
<td><%= link_to job.name, job_path(job, project_id: job.project_id) %></td>
<td><%= job.starts_on %></td>
<td><%= job.ends_on %></td>
<td><%= format_date(job.starts_on) %></td>
<td><%= format_date(job.ends_on) %></td>
<td><%= job.project_id %></td>
<td><%= job.external_project_id %></td>
<td><%= job.budget %></td>
<td>
<%= l_hours_short(job.total_time_logged) %> /
<%= l_hours_short(job.total_time_budget) %>
<progress max="<%= job.total_time_budget %>" value="<%= job.total_time_logged %>"></progress>
</td>
</tr>

View File

@ -27,12 +27,11 @@
<div class="status attribute">
<div class="label">Total:</div>
<div class="value">
<%= l_hours_short(@job.total_time_logged) %> / <%= l_hours_short(@job.budget) %>
<progress max="<%= @job.budget %>" value="<%= @job.total_time_logged %>"></progress>
<%= l_hours_short(@job.total_time_logged) %> / <%= l_hours_short(@job.total_time_budget) %>
<progress max="<%= @job.total_time_budget %>" value="<%= @job.total_time_logged %>"></progress>
</div>
<% TimeEntryActivity.all.each do |activity| %>
<div class="label"><%= activity.name %>:</div>
<div class="value"><%= l_hours_short(@job.total_time_logged_for(activity)) %></div>
<% @job.time_budgets.each do |budget| %>
<%= render partial: "budget", locals: { budget: budget } %>
<% end %>
</div>
</div>

View File

@ -7,7 +7,6 @@ class CreateJobs < ActiveRecord::Migration[6.1]
t.string :name, null: false
t.references :project, foreign_key: true
t.string :description
t.integer :budget
t.timestamps
end

View File

@ -0,0 +1,15 @@
class CreateTimeBudgets < ActiveRecord::Migration[6.1]
def change
create_table :time_budgets do |t|
t.integer :hours, null: false, default: 0
t.references :job, foreign_key: true, null: false
t.integer :activity_id, null: true
t.timestamps
end
add_index :time_budgets, [:job_id, :activity_id], unique: true
# TODO: We should use NULLS NOT DISTINCT to only allow one instance of nil activity per-Job also
# Requires Postgres 15
end
end