Class: TimeOffRequest

Inherits:
ApplicationRecord show all
Includes:
Models::Auditable
Defined in:
app/models/time_off_request.rb

Overview

== Schema Information

Table name: time_off_requests
Database name: primary

id :bigint not null, primary key
amount :decimal(, )
approved_on :date
balance_snapshot :jsonb
end_date :date
notes :text
over_request_acknowledgement :boolean default(FALSE)
projected_balance_at_request :decimal(, )
requested_on :date
start_date :date
state :string
work_schedule_snapshot :jsonb
created_at :datetime not null
updated_at :datetime not null
backup_employee_id :integer
creator_id :integer
employee_id :integer
google_event_id :string
time_off_type_id :integer

Indexes

index_time_off_requests_on_approved_on (approved_on)
index_time_off_requests_on_backup_employee_id (backup_employee_id)
index_time_off_requests_on_creator_id (creator_id)
index_time_off_requests_on_employee_id (employee_id)
index_time_off_requests_on_end_date (end_date)
index_time_off_requests_on_over_request_acknowledgement (over_request_acknowledgement)
index_time_off_requests_on_requested_on (requested_on)
index_time_off_requests_on_start_date (start_date)
index_time_off_requests_on_state (state)
index_time_off_requests_on_time_off_type_id (time_off_type_id)

Foreign Keys

fk_rails_... (backup_employee_id => parties.id)
fk_rails_... (creator_id => parties.id)
fk_rails_... (employee_id => parties.id)
fk_rails_... (time_off_type_id => time_off_types.id)

Constant Summary

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Instance Attribute Summary collapse

Belongs to collapse

Methods included from Models::Auditable

#updater

Has many collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#amountObject (readonly)



58
# File 'app/models/time_off_request.rb', line 58

validates :employee_id, :start_date, :end_date, :amount, :time_off_type_id, :notes, presence: true

#date_rangeObject

Returns the value of attribute date_range.



67
68
69
# File 'app/models/time_off_request.rb', line 67

def date_range
  @date_range
end

#employee_idObject (readonly)



58
# File 'app/models/time_off_request.rb', line 58

validates :employee_id, :start_date, :end_date, :amount, :time_off_type_id, :notes, presence: true

#end_dateObject (readonly)



58
# File 'app/models/time_off_request.rb', line 58

validates :employee_id, :start_date, :end_date, :amount, :time_off_type_id, :notes, presence: true

#google_calendar_errorObject

Returns the value of attribute google_calendar_error.



67
68
69
# File 'app/models/time_off_request.rb', line 67

def google_calendar_error
  @google_calendar_error
end

#notesObject (readonly)



58
# File 'app/models/time_off_request.rb', line 58

validates :employee_id, :start_date, :end_date, :amount, :time_off_type_id, :notes, presence: true

#skip_approvalObject

Returns the value of attribute skip_approval.



67
68
69
# File 'app/models/time_off_request.rb', line 67

def skip_approval
  @skip_approval
end

#start_dateObject (readonly)



58
# File 'app/models/time_off_request.rb', line 58

validates :employee_id, :start_date, :end_date, :amount, :time_off_type_id, :notes, presence: true

#time_off_type_idObject (readonly)



58
# File 'app/models/time_off_request.rb', line 58

validates :employee_id, :start_date, :end_date, :amount, :time_off_type_id, :notes, presence: true

Class Method Details

.approvedActiveRecord::Relation<TimeOffRequest>

A relation of TimeOffRequests that are approved. Active Record Scope

Returns:

See Also:



79
# File 'app/models/time_off_request.rb', line 79

scope :approved, -> { not_banked_time.where(state: 'approved') }

.build_planned_work_day_for_form(usual_schedule, requested_date) ⇒ Object

Merges the live "usual" weekly snapshot with any per-day override the employee saved on a prior edit.



238
239
240
241
242
243
244
245
# File 'app/models/time_off_request.rb', line 238

def self.build_planned_work_day_for_form(usual_schedule, requested_date)
  return {} if usual_schedule.blank?

  base = { 'usual' => usual_schedule.stringify_keys }
  stored = requested_date&.planned_work_day
  base['planned'] = stored['planned'] if stored.is_a?(Hash) && stored['planned'].present?
  base
end

.generate_request(start_date, end_date, time_off_type_id, employee_id, time_off_request_id) ⇒ Object



247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# File 'app/models/time_off_request.rb', line 247

def self.generate_request(start_date, end_date, time_off_type_id, employee_id, time_off_request_id)
  time_off_request = TimeOffRequest.find(time_off_request_id) if time_off_request_id.present?
  time_off_type = TimeOffType.find(time_off_type_id)
  employee = Employee.find(employee_id)
  department = employee.employee_record.department
  unit = time_off_type.unit
  range_dates = (start_date..end_date).to_a

  if time_off_request.present?
    overlapping_dates_exist = false
  else
    overlapping_dates_exist = TimeOffRequest.not_banked_time
                                            .joins(:time_off_request_dates)
                                            .where(employee_id: employee_id)
                                            .where(state: %w[approved requested])
                                            .where(time_off_request_dates: { date: range_dates })
                                            .exists?
    return { overlapping_dates: true } if overlapping_dates_exist && !time_off_type.exclude_from_overlapping_rules
  end

  # Fetch blocked days for the employee's department
  blocked_days = TimeOffBlockedDay.where(blocked_date: range_dates).select do |blocked_day|
    blocked_day.departments.include?(department)
  end

  user_selected_range_hash = range_dates.map do |date|
    date_id = nil
    existing_notes = nil
    requested_date = nil
    work_schedule_day = nil

    # Determine the type of day
    holiday = employee.company.company_holidays.find_by(holiday_date: date)
    is_holiday = holiday.present?
    holiday_name = holiday&.holiday_name

    is_blocked = blocked_days.find { |b| b.blocked_date == date }.present?
    is_weekend = date.saturday? || date.sunday?

    # Set the day explanation dynamically
    date_details = if is_holiday
                     { type: :holiday, message: holiday_name || 'Company Holiday', icon: 'gift' }
                   elsif is_blocked
                     { type: :blocked, message: 'Blocked for Requests', icon: 'lock' }
                   elsif is_weekend
                     { type: :weekend, message: nil, icon: nil }
                   else
                     { type: :workday, message: nil, icon: nil }
                   end

    # Default amounts
    if %i[holiday weekend].include?(date_details[:type])
      amount = 0
      max_amount = 0
      min_amount = 0
    elsif unit == 'hour'
      # Fetch the employee's work schedule for the specific day of the week
      work_schedule = employee.work_schedules.effective_on(date).first # we only expect one
      work_schedule_day = work_schedule.work_schedule_days.find_by(day_of_week: date.wday) if work_schedule

      # Use the hours from the work schedule if it exists; otherwise, fall back to time off type's default
      amount = work_schedule_day&.hours
      max_amount = amount
      min_amount = 1
    elsif unit == 'day'
      amount = 1
      max_amount = 1
      min_amount = 1
    end

    # If we have a blocked day, we allow the user to set the max amount. It will need to be approved by a manager.
    if date_details[:type] == :blocked
      amount = 0
      max_amount = max_amount
      min_amount = 0
    end

    if time_off_type_id.to_i == TimeOffType::SHORT_TERM_DISABILITY_ID
      min_amount = 0
    end

    if time_off_type_id.to_i == TimeOffType::BANKED_TIME_ID
      min_amount = 0
      max_amount = 12
      amount = 0
    end

    # At this point we hijack the amount, and if we have an existing time off request, we return the existing amount of the request
    if time_off_request.present?
      requested_date = time_off_request.time_off_request_dates.find_by(date: date)
      if requested_date.present?
        # It's possible that even if we are editing an existing request, we change the date range selection and include new dates,
        # so we need to check for the requested_date to exist
        date_id = requested_date&.id
        amount = requested_date&.amount if time_off_request.time_off_type.unit == unit
        existing_notes = requested_date&.notes
      end
    end

    usual_schedule = nil
    planned_work_day = {}
    if unit == 'hour' && work_schedule_day.present? && %i[holiday weekend].exclude?(date_details[:type])
      usual_schedule = work_schedule_day.time_off_request_day_payload.as_json
      planned_work_day = TimeOffRequest.build_planned_work_day_for_form(usual_schedule, requested_date)
    end

    # Finally we build the hash for each date
    {
      id: date_id,
      date: date,
      display_date: date.strftime('%a, %b %d'),
      amount: amount,
      max_amount: max_amount,
      min_amount: min_amount,
      details: date_details,
      unit: unit,
      notes: existing_notes,
      usual_schedule: usual_schedule,
      planned_work_day: planned_work_day,
      mark_for_deletion: false
    }
  end

  if time_off_request.present?
    existing_dates = time_off_request.time_off_request_dates.map(&:date)
    dates_to_remove = existing_dates - range_dates

    dates_to_remove_hash = dates_to_remove.map do |date|
      tord = time_off_request.time_off_request_dates.find_by(date: date)
      {
        id: tord.id,
        date: date,
        display_date: date.strftime('%a, %b %d'),
        amount: 0,
        max_amount: 0,
        min_amount: 0,
        details: nil,
        unit: nil,
        notes: nil,
        usual_schedule: nil,
        planned_work_day: {},
        mark_for_deletion: true
      }
    end
  end

  result_array = (user_selected_range_hash || []) + (dates_to_remove_hash || [])
  result_array = result_array.uniq

  # Now let's calculate the projected balance at the time of this request to know if the user has enough balance for this request
  calculated_balance = TimeOffBalanceCalculator.new(employee: employee, date: start_date, time_off_type: time_off_type).formatted_balances
  category, details = calculated_balance.first
  time_available_for_request = details[:time_available]

  # Final hash result
  { error: nil, dates: result_array, overlapping_dates: overlapping_dates_exist, time_available_for_request: time_available_for_request }
end

.not_banked_timeActiveRecord::Relation<TimeOffRequest>

A relation of TimeOffRequests that are not banked time. Active Record Scope

Returns:

See Also:



78
# File 'app/models/time_off_request.rb', line 78

scope :not_banked_time, -> { where.not(time_off_type_id: TimeOffType::BANKED_TIME_ID) }

.pendingActiveRecord::Relation<TimeOffRequest>

A relation of TimeOffRequests that are pending. Active Record Scope

Returns:

See Also:



80
# File 'app/models/time_off_request.rb', line 80

scope :pending, -> { where(state: 'requested') }

Instance Method Details

#banked_time_cannot_be_in_futureObject



136
137
138
139
140
141
# File 'app/models/time_off_request.rb', line 136

def banked_time_cannot_be_in_future
  # Check if the time-off type is "banked time" and if any date in the request is in the future
  return unless time_off_type == 'banked_time' && time_off_request_dates.any? { |date| date.date > Date.current }

  errors.add(:base, 'Banked time cannot be requested for future dates. Please select today or a past date.')
end

#banked_time_request?Boolean

Returns:

  • (Boolean)


205
206
207
# File 'app/models/time_off_request.rb', line 205

def banked_time_request?
  time_off_type_id == TimeOffType::BANKED_TIME_ID
end

#cannot_create_request_for_past_datesObject



143
144
145
146
147
148
149
150
# File 'app/models/time_off_request.rb', line 143

def cannot_create_request_for_past_dates
  return if start_date.blank?
  # Banked time is allowed for past dates (it's for logging overtime already worked)
  return if time_off_type_id == TimeOffType::BANKED_TIME_ID
  return if start_date >= Date.current

  errors.add(:start_date, "cannot be in the past. Time off requests must start today or later.")
end

#cannot_span_calendar_yearsObject



129
130
131
132
133
134
# File 'app/models/time_off_request.rb', line 129

def cannot_span_calendar_years
  return if start_date.blank? || end_date.blank?
  return if start_date.year == end_date.year

  errors.add(:end_date, "cannot be in a different year than the start date. Please create separate requests for each calendar year.")
end

#check_approval_requirementObject



193
194
195
196
197
# File 'app/models/time_off_request.rb', line 193

def check_approval_requirement
  return unless skip_approval.to_b

  approve # This will send a notification to manager an/or employee. If it's been auto approved maybe it shouldnt send it to the manager?
end

#correct_number_of_time_off_request_datesObject



152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'app/models/time_off_request.rb', line 152

def correct_number_of_time_off_request_dates
  # Ensure start_date and end_date are present
  if start_date && end_date
    expected_days_count = (start_date..end_date).to_a.size

    # Exclude dates marked for deletion
    actual_days_count = time_off_request_dates.reject { |date| date.marked_for_destruction? }.size

    errors.add(:base, "The number of time off request dates does not match the selected date range. Expected #{expected_days_count} dates but found #{actual_days_count}.") if actual_days_count != expected_days_count
  else
    errors.add(:base, 'Start date and end date must be present.')
  end
end

#create_banked_time_balanceObject

Create a time-off balance record for banked time



210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'app/models/time_off_request.rb', line 210

def create_banked_time_balance
  vacation_time_off_type = TimeOffType.find(TimeOffType::VACATION_ID)
  return unless vacation_time_off_type

  TimeOffBalance.create!(
    employee: employee,
    time_off_type: vacation_time_off_type,
    amount: amount,
    date: start_date,
    time_off_request_id: id,
    note: "Banked Time Accrual for Extra Hours Worked from #{start_date} to #{end_date}",
    category: 'banked'
  )
end

#creatorEmployee

Returns:

See Also:



54
# File 'app/models/time_off_request.rb', line 54

belongs_to  :creator, class_name: 'Employee'

#employeeEmployee

Returns:

See Also:



52
# File 'app/models/time_off_request.rb', line 52

belongs_to  :employee

#handle_approved_requestObject



199
200
201
202
203
# File 'app/models/time_off_request.rb', line 199

def handle_approved_request
  create_banked_time_balance if banked_time_request?
  send_to_google_calendar
  send_approved_notification
end

#no_overlapping_datesObject



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'app/models/time_off_request.rb', line 112

def no_overlapping_dates
  # Get all dates for the current request
  request_dates = time_off_request_dates.map(&:date)

  # Find other requests for the same employee that have overlapping dates
  overlapping_requests = TimeOffRequest.not_banked_time.joins(:time_off_request_dates)
                                       .where(employee_id: employee_id)
                                       .where(state: %w[approved requested])
                                       .where.not(id: id) # Exclude the current request in case of update
                                       .where(time_off_request_dates: { date: request_dates })

  # If there are any overlapping requests, add an error
  return unless overlapping_requests.exists? && !time_off_type.exclude_from_overlapping_rules

  errors.add(:base, 'There are overlapping time-off requests for the selected dates. Please delete any previous requests with overlaping dates.')
end

#notification_recipientsObject



426
427
428
429
430
431
432
433
434
435
436
437
438
439
# File 'app/models/time_off_request.rb', line 426

def notification_recipients
  emails = []
  emails << employee.manager.email if employee.manager.present?
  department = employee.employee_record.department

  case department
  when 'Sales'
    emails << Employee.find(6).email # Mary
    emails << Employee.find(6_855_834).email # Pauline
  when 'Warehouse'
    emails << Employee.find(6_855_834).email if employee.country_iso3 == 'CAN' # Pauline
  end
  emails
end

#parentObject



108
109
110
# File 'app/models/time_off_request.rb', line 108

def parent
  employee
end

#remove_from_google_calendarObject



419
420
421
422
423
424
# File 'app/models/time_off_request.rb', line 419

def remove_from_google_calendar
  TimeOffRequests::GoogleCalendar.new(self).delete_event
rescue StandardError => e
  Rails.logger.error("Failed to delete Google Calendar event: #{e.message}")
  self.google_calendar_error = 'There was an issue deleting the Google Calendar event.'
end

#reverse_balance_adjustmentsObject



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'app/models/time_off_request.rb', line 166

def reverse_balance_adjustments
  return if time_off_balances.any? { |a| a.note.start_with?('Reversal of') }

  time_off_balances.each do |balance|
    TimeOffBalance.find_or_create_by!(
      employee: balance.employee,
      time_off_type: balance.time_off_type,
      time_off_request_id: id,
      date: balance.date,
      amount: -balance.amount,
      category: balance.category
    ) do |tob|
      tob.note = "Reversal of #{balance.time_off_type.name} accrual for canceled #{time_off_type.name} request"
    end
  end
end

#send_approved_notificationObject



225
226
227
# File 'app/models/time_off_request.rb', line 225

def send_approved_notification
  InternalMailer.request_approved(self).deliver_now
end

#send_cancellation_notificationObject



233
234
235
# File 'app/models/time_off_request.rb', line 233

def send_cancellation_notification
  InternalMailer.request_cancelled(self).deliver_now
end

#send_denied_notificationObject



229
230
231
# File 'app/models/time_off_request.rb', line 229

def send_denied_notification
  InternalMailer.request_denied(self).deliver_now
end

#send_request_notificationObject



187
188
189
190
191
# File 'app/models/time_off_request.rb', line 187

def send_request_notification
  InternalMailer.new_time_off_request(self).deliver_now
  # TODO: Send notification to manager and to employee
  # ALSO TODO Check if the reques was created by a manager an approve it directly. Maybe give a checkbox in the request screen to auto approve or not.
end

#send_to_google_calendarObject



405
406
407
408
409
410
# File 'app/models/time_off_request.rb', line 405

def send_to_google_calendar
  TimeOffRequests::GoogleCalendar.new(self).create_event
rescue StandardError => e
  Rails.logger.error("Failed to create Google Calendar event: #{e.message}")
  self.google_calendar_error = 'There was an issue creating the Google Calendar event.'
end

#set_requested_on_dateObject



183
184
185
# File 'app/models/time_off_request.rb', line 183

def set_requested_on_date
  update(requested_on: Date.today)
end

#time_off_balancesActiveRecord::Relation<TimeOffBalance>

Returns:

See Also:



56
# File 'app/models/time_off_request.rb', line 56

has_many    :time_off_balances, dependent: :destroy

#time_off_request_datesActiveRecord::Relation<TimeOffRequestDate>

Returns:

See Also:



55
# File 'app/models/time_off_request.rb', line 55

has_many    :time_off_request_dates, dependent: :destroy

#time_off_typeTimeOffType



53
# File 'app/models/time_off_request.rb', line 53

belongs_to  :time_off_type

#update_google_calendar_eventObject



412
413
414
415
416
417
# File 'app/models/time_off_request.rb', line 412

def update_google_calendar_event
  TimeOffRequests::GoogleCalendar.new(self).update_event
rescue StandardError => e
  Rails.logger.error("Failed to update Google Calendar event: #{e.message}")
  self.google_calendar_error = 'There was an issue updating the Google Calendar event.'
end