Module: Crm::TimeOffsHelper

Includes:
TimeOffPlannedWorkDayHelper
Defined in:
app/helpers/crm/time_offs_helper.rb

Overview

View helpers for the CRM time offs area: manager dashboard grids and
badges, vacation summary charts, and the time off type/policy form
options used by +Crm::TimeOffsController+ and related views.

See Also:

Constant Summary collapse

MANAGER_GRID_LOCATIONS =

Icon + label for where the employee's latest CRM activity came from,
classified against IpDetector's fixed office blocks.

{
  us_office: { icon: 'building', label: 'LZ office' },
  ca_office: { icon: 'building', label: 'CA office' },
  in_office: { icon: 'building', label: 'India office' },
  remote: { icon: 'house-laptop', label: 'Remote' }
}.freeze
MANAGER_DEPARTMENT_ICON_BY_NAME =

Canonical department label (lowercase) -> Font Awesome icon name for fa_icon (sharp regular).
Used for exact match first, then substring scan (see MANAGER_DEPARTMENT_ICON_FRAGMENTS).
Includes administration / admin / no department; add new departments here only.

{
  'it' => 'building',
  'information technology' => 'building',
  'engineering' => 'screwdriver-wrench',
  'support' => 'headset',
  'customer support' => 'headset',
  'sales' => 'handshake',
  'marketing' => 'bullhorn',
  'finance' => 'coins',
  'accounting' => 'file-invoice-dollar',
  'hr' => 'people-group',
  'human resources' => 'people-group',
  'operations' => 'gears',
  'warehouse' => 'warehouse',
  'shipping' => 'truck',
  'logistics' => 'truck-fast',
  'legal' => 'scale-balanced',
  'executive' => 'briefcase',
  'management' => 'user-tie',
  'tech 24x7' => 'clock',
  'production' => 'industry',
  'quality' => 'clipboard-check',
  'purchasing' => 'cart-shopping',
  'buying' => 'cart-shopping',
  'administration' => 'shield-halved',
  'admin' => 'shield-halved',
  'no department' => 'users'
}.freeze
MANAGER_DEPARTMENT_ICON_FRAGMENT_EXCLUDE_KEYS =

Keys excluded from substring matching: +include?+ would false-positive (e.g. "digital" for "it").
Those labels are still handled by MANAGER_DEPARTMENT_ICON_BY_NAME (exact) or /\bit\b/ / /\bhr\b/ below.

%w[it hr].freeze
MANAGER_DEPARTMENT_ICON_FRAGMENTS =

Derived from MANAGER_DEPARTMENT_ICON_BY_NAME: longest keys first so "customer support" beats "support".

MANAGER_DEPARTMENT_ICON_BY_NAME
.reject { |k, _| MANAGER_DEPARTMENT_ICON_FRAGMENT_EXCLUDE_KEYS.include?(k) }
.sort_by { |k, _| [-k.length, k] }
.freeze
MANAGER_DEPARTMENT_ICON_FALLBACKS =

Manager department icon fallbacks.

%w[
  building
  briefcase
  users
  industry
  star
  diagram-project
].freeze
PENDING_BADGE_VARIANT_CLASSES =

Bootstrap background utilities for +pending_badge+ (+variant+ must be a known key).

{
  primary: 'bg-primary',
  warning: 'bg-warning text-dark'
}.freeze
TIME_OFF_TYPE_DISPLAY_TEXT_CLASS =

Maps time-off row type (TimeOffType#name or the literal "holiday") to Bootstrap 5 text utilities.
Output is always from a fixed allowlist — never interpolates DB color strings into markup.

{
  'holiday' => 'text-warning',
  'Vacation' => 'text-success',
  'Birthday' => 'text-warning',
  'Unpaid leave' => 'text-secondary',
  'Community Service' => 'text-success',
  'Short-Term Disability (STD)' => 'text-primary',
  'Jury Duty' => 'text-info',
  'Bereavement' => 'text-secondary',
  'Banked Time' => 'text-primary'
}.freeze
FALLBACK_TEXT_CLASSES =

Fallback text classes.

%w[
  text-primary text-secondary text-success text-danger text-warning text-info text-body-secondary
].freeze
TIME_OFF_TYPE_UNIT_OPTIONS =

Available time off type unit options.

[%w[Hours hour], %w[Days day]].freeze
TIME_OFF_TYPE_ICON_SUGGESTIONS =

Kebab-case Font Awesome icon names (no fa- prefix) for Tom Select suggestions.

%w[
  umbrella-beach calendar-days calendar-xmark calendar-check sun snowflake
  heart-pulse bandage person-falling plane car gift baby
  cross house-chimney clock mug-hot briefcase beach-ball
  ballot-check star sparkles martini-glass cake-candles hands-praying
  user-doctor stethoscope mask-face thermometer
].freeze
TIME_OFF_TYPE_COLOR_PICKER_DEFAULT =

Default swatch for <input type="color"> when none is set or value is not a hex the input accepts.

'#913120'
TIME_OFF_POLICY_ACCRUAL_FREQUENCY_OPTIONS =

Values match existing policy rows (e.g. seeds) and Employee#accrual handling uses .downcase.

%w[Weekly Monthly Yearly].map { |w| [w, w] }.freeze
TIME_OFF_POLICY_WEEKLY_DAY_OPTIONS =

Available time off policy weekly day options.

Date::DAYNAMES.each_with_index.map { |day, index| [day, index] }.freeze
TIME_OFF_POLICY_ANNUAL_ACCRUAL_TIMING_OPTIONS =

Available time off policy annual accrual timing options.

[
  ['Beginning of Year', 'beginning_of_year'],
  ['End of Year', 'end_of_year']
].freeze

Instance Method Summary collapse

Methods included from TimeOffPlannedWorkDayHelper

#time_off_planned_work_day_planned_hours, #time_off_planned_work_day_summary, #time_off_planned_work_day_usual_summary

Instance Method Details

#manager_department_icon(department_label) ⇒ String

Icon for a manager dashboard department heading (decorative; label remains plain text).

Parameters:

  • department_label (String)

    the department name to map to an icon

Returns:

  • (String)

    kebab-case Font Awesome icon name (no +fa-+ prefix)



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'app/helpers/crm/time_offs_helper.rb', line 96

def manager_department_icon(department_label)
  key = department_label.to_s.strip.downcase
  return 'users' if key.blank?

  icon = MANAGER_DEPARTMENT_ICON_BY_NAME[key]
  return icon if icon

  MANAGER_DEPARTMENT_ICON_FRAGMENTS.each do |fragment, frag_icon|
    return frag_icon if key.include?(fragment)
  end

  return 'building' if /\bit\b/.match?(key)
  return 'people-group' if /\bhr\b/.match?(key)

  idx = key.bytes.sum % MANAGER_DEPARTMENT_ICON_FALLBACKS.size
  MANAGER_DEPARTMENT_ICON_FALLBACKS[idx]
end

#manager_grid_location_line(employee) ⇒ ActiveSupport::SafeBuffer?

The small location line under a name on the who's-working grid, or nil
when the employee has no recent CRM activity (or the grid shows a past
date, in which case @employee_last_seen is empty).

Parameters:

Returns:

  • (ActiveSupport::SafeBuffer, nil)


26
27
28
29
30
31
32
33
34
35
36
37
# File 'app/helpers/crm/time_offs_helper.rb', line 26

def manager_grid_location_line(employee)
  seen = @employee_last_seen&.dig(employee.id)
  return if seen.blank?

  location = MANAGER_GRID_LOCATIONS[IpDetector.office_location(seen['ip'])]
  return if location.nil?

  tag.div(class: 'text-body-secondary', style: 'font-size: 0.7rem;',
          title: "Last CRM activity #{time_ago_in_words(Time.zone.at(seen['at']))} ago") do
    fa_icon(location[:icon], class: 'me-1', 'aria-hidden': 'true') + location[:label]
  end
end

#manager_schedule_grid_cell_kind(row_data, slot, slot_end) ⇒ Symbol

Row hash from Crm::TimeOffsController#setup_manager_dashboard_data (+ :time_off_request_date).

Parameters:

  • row_data (Hash)

    the schedule row hash (+:schedule_day+,
    +:time_off_request_date+, +:ongoing_request+)

  • slot (Float)

    grid slot start hour

  • slot_end (Float)

    grid slot end hour

Returns:

  • (Symbol)

    the cell kind (e.g. +:off+, +:lunch+, +:work+)

See Also:



312
313
314
315
316
317
318
319
320
# File 'app/helpers/crm/time_offs_helper.rb', line 312

def manager_schedule_grid_cell_kind(row_data, slot, slot_end)
  Crm::ManagerScheduleGridSlot.cell_kind(
    slot: slot,
    slot_end: slot_end,
    schedule_day: row_data[:schedule_day],
    time_off_request_date: row_data[:time_off_request_date],
    ongoing_time_off_request: row_data[:ongoing_request]
  )
end

#pending_badge(count, variant: :primary, title: '', extra_classes: '') ⇒ ActiveSupport::SafeBuffer?

Pill badge for manager employee chip pending counts; returns nil when +count+ is not positive.

Parameters:

  • count (Integer)

    the pending count to display

  • variant (Symbol) (defaults to: :primary)

    badge style; must be a key of PENDING_BADGE_VARIANT_CLASSES

  • title (String) (defaults to: '')

    optional tooltip text

  • extra_classes (String) (defaults to: '')

    optional additional CSS classes

Returns:

  • (ActiveSupport::SafeBuffer, nil)

    the badge markup, or nil for non-positive counts

Raises:

  • (KeyError)

    when +variant+ is not a known PENDING_BADGE_VARIANT_CLASSES key



128
129
130
131
132
133
134
135
136
137
# File 'app/helpers/crm/time_offs_helper.rb', line 128

def pending_badge(count, variant: :primary, title: '', extra_classes: '')
  return nil unless count.respond_to?(:positive?) && count.positive?

  variant_classes = PENDING_BADGE_VARIANT_CLASSES.fetch(variant.to_sym)
  class_parts = ['badge', 'rounded-pill', variant_classes]
  class_parts.concat(extra_classes.to_s.split) if extra_classes.present?
  attrs = { class: class_parts.join(' ') }
  attrs[:title] = title if title.present?
  tag.span(count.to_s, **attrs)
end

#time_off_policy_parent_options(record) ⇒ Array<Array(String, Integer)>

[name, id] options for a time off policy's parent select, excluding the
record itself when persisted.

Parameters:

Returns:

  • (Array<Array(String, Integer)>)

    [name, id] pairs ordered by name



298
299
300
301
302
# File 'app/helpers/crm/time_offs_helper.rb', line 298

def time_off_policy_parent_options(record)
  scope = TimeOffPolicy.order(:name)
  scope = scope.excluding(record) if record.persisted?
  scope.pluck(:name, :id)
end

#time_off_type_color_class(type) ⇒ String

Maps a time-off row type to a Bootstrap 5 text utility class, falling
back to a stable pick from FALLBACK_TEXT_CLASSES for unknown names.

Parameters:

  • type (String)

    TimeOffType#name or the literal "holiday"

Returns:

  • (String)

    a text utility class from the fixed allowlist



163
164
165
166
167
168
169
170
# File 'app/helpers/crm/time_offs_helper.rb', line 163

def time_off_type_color_class(type)
  key = type.to_s
  return TIME_OFF_TYPE_DISPLAY_TEXT_CLASS[key] if TIME_OFF_TYPE_DISPLAY_TEXT_CLASS.key?(key)

  # Unknown type names (e.g. newly added in admin): pick a stable class from the allowlist only.
  idx = key.bytes.sum % FALLBACK_TEXT_CLASSES.size
  FALLBACK_TEXT_CLASSES[idx]
end

#time_off_type_color_picker_value(record) ⇒ String

Normalizes a TimeOffType color for the HTML color input; falls back when
the DB has named colors or invalid values.

Parameters:

  • record (TimeOffType)

    the type whose +color+ is rendered

Returns:

  • (String)

    a +#rrggbb+ hex value acceptable to ++



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'app/helpers/crm/time_offs_helper.rb', line 250

def time_off_type_color_picker_value(record)
  c = record.color.to_s.strip
  return TIME_OFF_TYPE_COLOR_PICKER_DEFAULT if c.blank?

  case c
  when /\A#[0-9a-fA-F]{6}\z/i
    c
  when /\A#[0-9a-fA-F]{3}\z/i
    "##{c[1]}#{c[1]}#{c[2]}#{c[2]}#{c[3]}#{c[3]}"
  when /\A#[0-9a-fA-F]{8}\z/i
    c[0..6]
  else
    TIME_OFF_TYPE_COLOR_PICKER_DEFAULT
  end
end

#time_off_type_icon_tom_select_collection(record) ⇒ Array<Array(String, String)>

Pairs [label, value] for icon Tom Select; includes current value if not in the suggestion list.

Parameters:

  • record (TimeOffType)

    the type whose +icon+ preselects an option

Returns:

  • (Array<Array(String, String)>)

    [label, value] pairs for the select



270
271
272
273
274
275
276
277
278
# File 'app/helpers/crm/time_offs_helper.rb', line 270

def time_off_type_icon_tom_select_collection(record)
  normalized = record.icon.to_s.strip.sub(/\Afa-/, '').tr('_', '-').downcase
  pairs = TIME_OFF_TYPE_ICON_SUGGESTIONS.map { |i| [i.tr('-', ' ').titleize, i] }
  if normalized.present? && TIME_OFF_TYPE_ICON_SUGGESTIONS.exclude?(normalized)
    pairs = pairs.dup
    pairs.unshift(["#{normalized.tr('-', ' ').titleize} (current)", normalized])
  end
  pairs
end

#vacation_summary_bar_chart_data(summary) ⇒ Hash?

Series for the Summary tab bar chart (horizontal bars, same metrics as the KPI cards).

Parameters:

  • summary (Hash, nil)

    the vacation summary hash from the controller

Returns:

  • (Hash, nil)

    +{ rows: Array, max_value: Float }+, or nil when blank



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'app/helpers/crm/time_offs_helper.rb', line 189

def vacation_summary_bar_chart_data(summary)
  return if summary.blank?

  rows = [
    {
      label: 'Used time as of today',
      value: summary[:used_until_today].to_f,
      icon: 'clock',
      fill_class: 'bg-danger'
    },
    {
      label: 'Available time as of today',
      value: summary[:available_today].to_f,
      icon: 'circle-check',
      fill_class: 'bg-success'
    },
    {
      label: 'Approved planned until year-end',
      value: summary[:approved_between_today_and_eoy].to_f,
      icon: 'calendar-check',
      fill_class: 'bg-warning'
    },
    {
      label: 'Pending approval until year-end',
      value: summary[:pending_between_today_and_eoy].to_f,
      icon: 'hourglass-half',
      fill_class: 'bg-secondary'
    },
    {
      label: 'Available at year-end',
      value: summary[:available_eoy].to_f,
      icon: 'calendar-xmark',
      fill_class: 'bg-primary'
    }
  ]
  max_value = rows.pluck(:value).max.to_f
  max_value = 1.0 if max_value <= 0

  { rows:, max_value: }
end

#vacation_summary_hours_display(value) ⇒ Integer, Float

Formats a vacation summary hour value for display; matches
TimeOffBalanceCalculator#format_number for raw values.

Parameters:

  • value (Numeric, String, nil)

    the raw hour value

Returns:

  • (Integer, Float)

    whole hours as Integer, otherwise rounded to 2 decimals



177
178
179
180
181
182
183
# File 'app/helpers/crm/time_offs_helper.rb', line 177

def vacation_summary_hours_display(value)
  return 0 if value.blank?

  n = value.to_f
  rounded = n.round(2)
  (rounded % 1).zero? ? rounded.to_i : rounded
end