From 4b83753e71fadfbfb4b300ee495896e0e9bbcbdc Mon Sep 17 00:00:00 2001 From: Trevor Vallender Date: Fri, 24 Nov 2023 15:05:38 +0000 Subject: [PATCH] Time budgets per activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/controllers/jobs_controller.rb | 16 +++++++++-- app/models/job.rb | 28 +++++++++++++++++-- app/models/time_budget.rb | 11 ++++++++ app/views/jobs/_budget.html.erb | 11 ++++++++ app/views/jobs/_form.html.erb | 20 +++++++------ app/views/jobs/_job.html.erb | 10 +++++-- app/views/jobs/show.html.erb | 9 +++--- db/migrate/20231116202759_create_jobs.rb | 1 - .../20231123111018_create_time_budgets.rb | 15 ++++++++++ 9 files changed, 98 insertions(+), 23 deletions(-) create mode 100644 app/models/time_budget.rb create mode 100644 app/views/jobs/_budget.html.erb create mode 100644 db/migrate/20231123111018_create_time_budgets.rb diff --git a/app/controllers/jobs_controller.rb b/app/controllers/jobs_controller.rb index 6e2bc41..ba00706 100644 --- a/app/controllers/jobs_controller.rb +++ b/app/controllers/jobs_controller.rb @@ -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 diff --git a/app/models/job.rb b/app/models/job.rb index ff6ad68..55d3a29 100644 --- a/app/models/job.rb +++ b/app/models/job.rb @@ -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) diff --git a/app/models/time_budget.rb b/app/models/time_budget.rb new file mode 100644 index 0000000..83198b4 --- /dev/null +++ b/app/models/time_budget.rb @@ -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 diff --git a/app/views/jobs/_budget.html.erb b/app/views/jobs/_budget.html.erb new file mode 100644 index 0000000..1f54c16 --- /dev/null +++ b/app/views/jobs/_budget.html.erb @@ -0,0 +1,11 @@ +
<%= budget.activity.name %>
+
+ <%= l_hours_short( + budget.job.total_time_logged_for(budget.activity) + ) %> / + <%= l_hours_short( + budget.hours + ) %> + +
diff --git a/app/views/jobs/_form.html.erb b/app/views/jobs/_form.html.erb index 46a7a26..8770a86 100644 --- a/app/views/jobs/_form.html.erb +++ b/app/views/jobs/_form.html.erb @@ -3,34 +3,36 @@

<%= f.label :name %> <%= f.text_field :name %> -

<%= f.label :description %> <%= f.text_area :description %> -

<%= f.label :starts_on %> <%= f.date_field :starts_on %> -

<%= f.label :ends_on %> <%= f.date_field :ends_on %> -

<%= f.hidden_field :project_id, value: @project.id %>

<%= f.label :external_project_id %> <%= f.number_field :external_project_id %> -

-

- <%= f.label :budget %> - <%= f.number_field :budget %> -

+
+ Budget + <%= f.fields_for :time_budgets do |ff| %> +

+ <%= 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 %> +

<%= f.submit %> diff --git a/app/views/jobs/_job.html.erb b/app/views/jobs/_job.html.erb index d29dee7..f3253b1 100644 --- a/app/views/jobs/_job.html.erb +++ b/app/views/jobs/_job.html.erb @@ -1,9 +1,13 @@ <%= link_to job.name, job_path(job, project_id: job.project_id) %> - <%= job.starts_on %> - <%= job.ends_on %> + <%= format_date(job.starts_on) %> + <%= format_date(job.ends_on) %> <%= job.project_id %> <%= job.external_project_id %> - <%= job.budget %> + + <%= l_hours_short(job.total_time_logged) %> / + <%= l_hours_short(job.total_time_budget) %> + + diff --git a/app/views/jobs/show.html.erb b/app/views/jobs/show.html.erb index 02490ad..a61bdfa 100644 --- a/app/views/jobs/show.html.erb +++ b/app/views/jobs/show.html.erb @@ -27,12 +27,11 @@
Total:
- <%= l_hours_short(@job.total_time_logged) %> / <%= l_hours_short(@job.budget) %> - + <%= l_hours_short(@job.total_time_logged) %> / <%= l_hours_short(@job.total_time_budget) %> +
- <% TimeEntryActivity.all.each do |activity| %> -
<%= activity.name %>:
-
<%= l_hours_short(@job.total_time_logged_for(activity)) %>
+ <% @job.time_budgets.each do |budget| %> + <%= render partial: "budget", locals: { budget: budget } %> <% end %>
diff --git a/db/migrate/20231116202759_create_jobs.rb b/db/migrate/20231116202759_create_jobs.rb index bcef011..7ac7f9e 100644 --- a/db/migrate/20231116202759_create_jobs.rb +++ b/db/migrate/20231116202759_create_jobs.rb @@ -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 diff --git a/db/migrate/20231123111018_create_time_budgets.rb b/db/migrate/20231123111018_create_time_budgets.rb new file mode 100644 index 0000000..c9be306 --- /dev/null +++ b/db/migrate/20231123111018_create_time_budgets.rb @@ -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