hr_lite 0.30.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/app/assets/javascripts/hr_lite/chart_tip.js +33 -0
  4. data/app/assets/javascripts/hr_lite/countdown.js +12 -0
  5. data/app/assets/javascripts/hr_lite/crm_picker.js +27 -0
  6. data/app/assets/javascripts/hr_lite/emoji_pick.js +11 -0
  7. data/app/assets/stylesheets/hr_lite/hr_lite.css +47 -0
  8. data/app/controllers/hr_lite/admin/attendances_controller.rb +8 -5
  9. data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
  10. data/app/controllers/hr_lite/admin/holidays_controller.rb +17 -9
  11. data/app/controllers/hr_lite/admin/settings_controller.rb +1 -1
  12. data/app/controllers/hr_lite/admin/wfh_requests_controller.rb +103 -0
  13. data/app/controllers/hr_lite/admin/work_locations_controller.rb +41 -0
  14. data/app/controllers/hr_lite/admin/work_modes_controller.rb +72 -0
  15. data/app/controllers/hr_lite/admin/work_time_controller.rb +39 -0
  16. data/app/controllers/hr_lite/attendance_controller.rb +2 -1
  17. data/app/controllers/hr_lite/calendar_controller.rb +3 -2
  18. data/app/controllers/hr_lite/comp_off_requests_controller.rb +1 -1
  19. data/app/controllers/hr_lite/holidays_controller.rb +3 -2
  20. data/app/controllers/hr_lite/team_controller.rb +13 -0
  21. data/app/controllers/hr_lite/wfh_requests_controller.rb +45 -0
  22. data/app/controllers/hr_lite/work_locations_controller.rb +44 -0
  23. data/app/controllers/hr_lite/work_modes_controller.rb +40 -0
  24. data/app/controllers/hr_lite/work_time_controller.rb +14 -0
  25. data/app/helpers/hr_lite/application_helper.rb +43 -2
  26. data/app/jobs/hr_lite/attendance_close_job.rb +12 -5
  27. data/app/jobs/hr_lite/calendar_meeting_start_job.rb +31 -0
  28. data/app/jobs/hr_lite/calendar_meeting_sync_job.rb +55 -0
  29. data/app/jobs/hr_lite/work_mode_expire_job.rb +12 -0
  30. data/app/models/hr_lite/attendance_penalty.rb +2 -2
  31. data/app/models/hr_lite/attendance_record.rb +8 -0
  32. data/app/models/hr_lite/comp_off_request.rb +2 -2
  33. data/app/models/hr_lite/employee_profile.rb +2 -0
  34. data/app/models/hr_lite/holiday.rb +11 -5
  35. data/app/models/hr_lite/leave_balance.rb +1 -1
  36. data/app/models/hr_lite/leave_request.rb +1 -1
  37. data/app/models/hr_lite/regularization_request.rb +1 -1
  38. data/app/models/hr_lite/setting.rb +2 -0
  39. data/app/models/hr_lite/wfh_request.rb +108 -0
  40. data/app/models/hr_lite/work_location.rb +62 -0
  41. data/app/models/hr_lite/work_mode_event.rb +84 -0
  42. data/app/services/hr_lite/attendance_puncher.rb +79 -4
  43. data/app/services/hr_lite/day_status.rb +1 -1
  44. data/app/services/hr_lite/leave_day_counter.rb +5 -7
  45. data/app/services/hr_lite/team_day.rb +6 -6
  46. data/app/services/hr_lite/today_checkins.rb +41 -0
  47. data/app/services/hr_lite/work_mode_durations.rb +237 -0
  48. data/app/services/hr_lite/work_mode_switch.rb +144 -0
  49. data/app/services/hr_lite/work_status.rb +60 -0
  50. data/app/services/hr_lite/working_calendar.rb +11 -2
  51. data/app/views/hr_lite/admin/attendances/show.html.erb +1 -1
  52. data/app/views/hr_lite/admin/employees/_form.html.erb +9 -0
  53. data/app/views/hr_lite/admin/holidays/index.html.erb +10 -1
  54. data/app/views/hr_lite/admin/settings/edit.html.erb +9 -0
  55. data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +7 -1
  56. data/app/views/hr_lite/admin/wfh_requests/index.html.erb +91 -0
  57. data/app/views/hr_lite/admin/wfh_requests/show.html.erb +40 -0
  58. data/app/views/hr_lite/admin/work_locations/index.html.erb +42 -0
  59. data/app/views/hr_lite/admin/work_modes/index.html.erb +105 -0
  60. data/app/views/hr_lite/admin/work_time/index.html.erb +42 -0
  61. data/app/views/hr_lite/attendance/_calendar_connection.html.erb +15 -0
  62. data/app/views/hr_lite/attendance/_crm_picker.html.erb +9 -0
  63. data/app/views/hr_lite/attendance/_custom_status.html.erb +26 -0
  64. data/app/views/hr_lite/attendance/_punch_card.html.erb +23 -0
  65. data/app/views/hr_lite/attendance/_work_mode.html.erb +92 -0
  66. data/app/views/hr_lite/attendance/show.html.erb +4 -1
  67. data/app/views/hr_lite/holidays/_next_holiday.html.erb +1 -1
  68. data/app/views/hr_lite/home/_today_checkins.html.erb +31 -0
  69. data/app/views/hr_lite/home/index.html.erb +5 -0
  70. data/app/views/hr_lite/team/show.html.erb +25 -4
  71. data/app/views/hr_lite/wfh_requests/index.html.erb +70 -0
  72. data/app/views/hr_lite/work_locations/index.html.erb +52 -0
  73. data/app/views/hr_lite/work_time/_bars.html.erb +44 -0
  74. data/app/views/hr_lite/work_time/_donut.html.erb +36 -0
  75. data/app/views/hr_lite/work_time/_report.html.erb +74 -0
  76. data/app/views/hr_lite/work_time/_timeline.html.erb +26 -0
  77. data/app/views/hr_lite/work_time/show.html.erb +10 -0
  78. data/config/routes.rb +22 -0
  79. data/db/migrate/20261013100000_create_hr_lite_wfh_requests.rb +22 -0
  80. data/db/migrate/20261013100100_add_wfh_request_id_to_hr_lite_attendance_records.rb +7 -0
  81. data/db/migrate/20261013100200_add_permanent_wfh_to_hr_lite_employee_profiles.rb +5 -0
  82. data/db/migrate/20261013110000_create_hr_lite_work_locations.rb +23 -0
  83. data/db/migrate/20261013110100_add_work_mode_to_hr_lite_attendance_records.rb +7 -0
  84. data/db/migrate/20261013110200_add_field_staff_to_hr_lite_employee_profiles.rb +5 -0
  85. data/db/migrate/20261013110300_create_hr_lite_work_mode_events.rb +30 -0
  86. data/db/migrate/20261013110400_add_break_minutes_to_hr_lite_settings.rb +6 -0
  87. data/db/migrate/20261013120000_grant_hr_lite_work_analytics.rb +14 -0
  88. data/db/migrate/20261013130000_add_office_location_to_hr_lite_holidays.rb +12 -0
  89. data/db/migrate/20261013130100_add_office_location_to_hr_lite_employee_profiles.rb +6 -0
  90. data/db/migrate/20261013140000_add_calendar_and_crm_to_hr_lite_work_mode_events.rb +9 -0
  91. data/db/migrate/20261013140100_add_custom_status_to_hr_lite_work_mode_events.rb +9 -0
  92. data/lib/hr_lite/configuration.rb +16 -1
  93. data/lib/hr_lite/notifications.rb +13 -4
  94. data/lib/hr_lite/permissions.rb +1 -0
  95. data/lib/hr_lite/role_seeds.rb +4 -2
  96. data/lib/hr_lite/seeds.rb +1 -1
  97. data/lib/hr_lite/version.rb +1 -1
  98. metadata +53 -1
@@ -0,0 +1,144 @@
1
+ module HrLite
2
+ # The one write path for a chosen work mode (employee or HR). A mode is a
3
+ # status: it never changes where the day's attendance was authorised, and
4
+ # nothing here touches the attendance record.
5
+ class WorkModeSwitch
6
+ SELECTABLE = %w[office wfh travelling outside_meeting meeting lunch short_break busy out_of_office].freeze
7
+ OUT_OF_OFFICE_REASONS = { "personal" => "Personal errand", "medical" => "Medical", "family" => "Family",
8
+ "official" => "Official work", "other" => "Other" }.freeze
9
+
10
+ Result = Struct.new(:event, :error) do
11
+ def ok? = error.nil?
12
+ end
13
+
14
+ def self.call(...) = new(...).call
15
+
16
+ CLEAR_AFTER = { "30" => 30.minutes, "60" => 1.hour, "240" => 4.hours, "today" => :today, "never" => nil }.freeze
17
+
18
+ # A person's own status, Slack-style: works with or without a check-in
19
+ # and clears itself on the chosen timer.
20
+ def self.set_status(user:, text:, emoji:, clear_after:)
21
+ now = Time.current
22
+ span = CLEAR_AFTER.fetch(clear_after.to_s, :today)
23
+ ends_at = span == :today ? now.in_time_zone(HrLite.config.time_zone).end_of_day : (span && now + span)
24
+ event = WorkModeEvent.start!(user_id: user.id, mode: "custom", source: "employee", actor: user, at: now,
25
+ title: text.to_s.strip.truncate(100).presence, emoji: emoji.to_s.strip.presence, ends_at: ends_at)
26
+ WorkModeExpireJob.set(wait_until: ends_at).perform_later(event.id) if ends_at
27
+ Result.new(event, nil)
28
+ rescue ActiveRecord::RecordInvalid => e
29
+ Result.new(nil, e.record.errors.full_messages.to_sentence)
30
+ end
31
+
32
+ # params: title, client, destination, reason, minutes, until ("HH:MM" today)
33
+ def initialize(user:, mode:, source:, actor:, params: {})
34
+ @user = user
35
+ @mode = mode.to_s
36
+ @source = source
37
+ @actor = actor
38
+ @p = params.to_h.symbolize_keys.transform_values { |v| v.to_s.strip.presence }.compact
39
+ @now = Time.current
40
+ end
41
+
42
+ def call
43
+ return fail!("Pick a work mode.") unless SELECTABLE.include?(@mode)
44
+ return fail!("A reason is required for an HR change.") if @source == "hr" && @p[:reason].blank?
45
+
46
+ status = WorkStatus.for(@user.id, at: @now)
47
+ return fail!("#{status.primary.label} today — work modes are off. Use the leave screen to change it.") if %w[holiday on_leave sick_leave].include?(status.primary.mode)
48
+ return fail!("Check in first; your mode starts with your check-in.") unless checked_in?
49
+
50
+ attrs = send("#{@mode}_attrs")
51
+ return fail!(attrs) if attrs.is_a?(String)
52
+
53
+ event = WorkModeEvent.start!(user_id: @user.id, mode: @mode, source: @source, actor: @actor, at: @now,
54
+ attendance_record: @record, **attrs)
55
+ WorkModeExpireJob.set(wait_until: event.ends_at).perform_later(event.id) if event.ends_at
56
+ Result.new(event, nil)
57
+ rescue ActiveRecord::RecordInvalid => e
58
+ fail!(e.record.errors.full_messages.to_sentence)
59
+ end
60
+
61
+ private
62
+
63
+ def fail!(message) = Result.new(nil, message)
64
+
65
+ def checked_in?
66
+ @record = AttendanceRecord.find_by(user_id: @user.id, date: @now.in_time_zone(HrLite.config.time_zone).to_date)
67
+ @record&.check_in_at.present? && @record.check_out_at.nil?
68
+ end
69
+
70
+ def base = { reason: @p[:reason] }
71
+
72
+ def office_attrs = base
73
+
74
+ def wfh_attrs
75
+ approved = WfhRequest.approved_for(@user.id, @record.date) ||
76
+ EmployeeProfile.where(user_id: @user.id).pick(:permanent_wfh)
77
+ return "WFH needs an approved WFH request for today." unless approved
78
+
79
+ base.merge(work_location_id: WorkLocation.active_for(@user.id)&.id)
80
+ end
81
+
82
+ def travelling_attrs
83
+ return "Say where you are going and why." unless @p[:destination] && @p[:title]
84
+
85
+ with_lead(base.merge(destination: @p[:destination], title: @p[:title], client: @p[:client])) { |a| timed(a, required: false) }
86
+ end
87
+
88
+ def outside_meeting_attrs
89
+ return "Say who you are meeting and why." unless (@p[:client] || @p[:crm_ref]) && @p[:title]
90
+
91
+ with_lead(base.merge(client: @p[:client], title: @p[:title], destination: @p[:destination])) { |a| timed(a, required: false) }
92
+ end
93
+
94
+ def meeting_attrs
95
+ return "Give the meeting a title." unless @p[:title]
96
+
97
+ with_lead(base.merge(title: @p[:title], client: @p[:client])) { |a| timed(a) }
98
+ end
99
+
100
+ # A picked CRM lead must exist (config.crm_find); its name fills the client.
101
+ def with_lead(attrs)
102
+ return yield(attrs) unless @p[:crm_ref]
103
+
104
+ lead = HrLite.config.crm_find&.call(@p[:crm_ref])
105
+ return "That lead was not found." unless lead
106
+
107
+ yield(attrs.merge(crm_ref: @p[:crm_ref], client: attrs[:client] || lead[:label].to_s.truncate(120)))
108
+ end
109
+
110
+ def busy_attrs = timed(base)
111
+
112
+ def out_of_office_attrs
113
+ return "Pick a reason for being out." unless OUT_OF_OFFICE_REASONS.key?(@p[:category])
114
+
115
+ timed(base.merge(title: OUT_OF_OFFICE_REASONS[@p[:category]]))
116
+ end
117
+
118
+ # One lunch a day: a fresh start can never reset the clock.
119
+ def lunch_attrs
120
+ day = @now.in_time_zone(HrLite.config.time_zone).all_day
121
+ return "You have already taken lunch today." if WorkModeEvent.where(user_id: @user.id, mode: "lunch", started_at: day).exists?
122
+
123
+ base.merge(ends_at: @now + Setting.instance.lunch_break_minutes.minutes)
124
+ end
125
+
126
+ def short_break_attrs = base.merge(ends_at: @now + Setting.instance.short_break_minutes.minutes)
127
+
128
+ def parse_until
129
+ Time.zone.parse("#{@now.to_date} #{@p[:until]}") if @p[:until]
130
+ rescue ArgumentError
131
+ nil
132
+ end
133
+
134
+ # minutes (30/60/…) or until "HH:MM" today; must be in the future, today.
135
+ def timed(attrs, required: true)
136
+ ends_at = @p[:minutes] ? @now + @p[:minutes].to_i.minutes : parse_until
137
+ return attrs unless ends_at || required
138
+ return "Pick when it ends." unless ends_at
139
+ return "The end time must be later today." unless ends_at > @now && ends_at <= @now.end_of_day
140
+
141
+ attrs.merge(ends_at: ends_at)
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,60 @@
1
+ module HrLite
2
+ # What someone is doing right now. Precedence: holiday, then full-day leave
3
+ # (both read from their own records — no status row is ever invented), then
4
+ # the open primary work mode with its overlay unless that has run out, then
5
+ # weekend, then Offline. Showing a status never grants attendance.
6
+ class WorkStatus
7
+ DERIVED = { "holiday" => "Holiday", "on_leave" => "On leave", "sick_leave" => "Sick leave",
8
+ "weekend" => "Weekend", "offline" => "Offline" }.freeze
9
+ SICK_CODE = "SL".freeze
10
+
11
+ Entry = Struct.new(:mode, :label, :source, :since, :until, :place, :event, keyword_init: true)
12
+ Status = Struct.new(:primary, :overlay, :custom, :half_day_leave, :updated_at, keyword_init: true) do
13
+ def available? = primary.mode.in?(%w[office wfh travelling outside_meeting]) && (overlay.nil? || overlay.mode == "meeting")
14
+ end
15
+
16
+ def self.for(user_id, at: Time.current) = for_users([ user_id ], at: at)[user_id]
17
+
18
+ # When an open Busy or Meeting ends, or nil when the person is reachable.
19
+ def self.do_not_disturb_until(user_id, at: Time.current)
20
+ WorkModeEvent.open.overlay.where(user_id: user_id, mode: %w[busy meeting]).where("ends_at > ?", at).maximum(:ends_at)
21
+ end
22
+
23
+ def self.for_users(user_ids, at: Time.current)
24
+ date = at.in_time_zone(HrLite.config.time_zone).to_date
25
+ events = WorkModeEvent.open.where(user_id: user_ids).group_by(&:user_id)
26
+ leaves = LeaveRequest.active_on(date).includes(:leave_type).where(user_id: user_ids).group_by(&:user_id)
27
+ offices = EmployeeProfile.where(user_id: user_ids).pluck(:user_id, :office_location_id).to_h
28
+ calendars = WorkingCalendar.by_office(date..date)
29
+ user_ids.index_with { |id| build(date, at, calendars[offices[id]], events.fetch(id, []), leaves.fetch(id, [])) }
30
+ end
31
+
32
+ def self.build(date, at, calendar, events, leaves)
33
+ full = leaves.find { |l| !l.half_day }
34
+ primary_event = events.find { |e| e.layer == "primary" }
35
+ overlay_event = events.find { |e| e.layer == "overlay" && !e.expired?(at) }
36
+
37
+ primary =
38
+ if calendar.holiday?(date) then derived("holiday")
39
+ elsif full then derived(full.leave_type&.code == SICK_CODE ? "sick_leave" : "on_leave", until_at: full.end_date)
40
+ elsif primary_event then from(primary_event)
41
+ elsif calendar.weekend?(date) then derived("weekend")
42
+ else derived("offline")
43
+ end
44
+ overlay = from(overlay_event) if overlay_event && primary.event
45
+ custom = events.find { |e| e.layer == "status" && !e.expired?(at) }
46
+
47
+ Status.new(primary: primary, overlay: overlay, custom: custom, half_day_leave: leaves.find(&:half_day),
48
+ updated_at: events.reject { |e| e.layer == "status" }.map(&:started_at).max)
49
+ end
50
+
51
+ def self.derived(mode, until_at: nil) = Entry.new(mode: mode, label: DERIVED[mode], source: "system", until: until_at)
52
+
53
+ def self.from(event)
54
+ Entry.new(mode: event.mode, label: event.label, source: event.source, since: event.started_at,
55
+ until: event.ends_at, place: event.place, event: event)
56
+ end
57
+
58
+ private_class_method :build, :derived, :from
59
+ end
60
+ end
@@ -3,12 +3,21 @@ module HrLite
3
3
  # weekend policy once per instance; every other service (leave counting,
4
4
  # day status, attendance summary) delegates here.
5
5
  class WorkingCalendar
6
- def initialize(range)
6
+ # office_id: that office's holidays count too (nil = all-office ones only).
7
+ def initialize(range, office_id: nil)
7
8
  @range = range
8
- @holiday_dates = Holiday.dates_for(range)
9
+ @holiday_dates = Holiday.dates_for(range, office_id)
9
10
  @policy = Setting.instance.weekend_policy
10
11
  end
11
12
 
13
+ def self.office_for(user_id) = EmployeeProfile.where(user_id: user_id).pick(:office_location_id)
14
+
15
+ # One person's calendar: their office's holidays plus the all-office ones.
16
+ def self.for_user(user_id, range) = new(range, office_id: office_for(user_id))
17
+
18
+ # Many people at once: one calendar per office, built on first use.
19
+ def self.by_office(range) = Hash.new { |h, office_id| h[office_id] = new(range, office_id: office_id) }
20
+
12
21
  def holiday?(date)
13
22
  @holiday_dates.include?(date)
14
23
  end
@@ -39,7 +39,7 @@
39
39
  <% [ [ "In", record.check_in_at, record.check_in_lat, record.check_in_lng ],
40
40
  [ "Out", record.check_out_at, record.check_out_lat, record.check_out_lng ] ].each do |label, at, lat, lng| %>
41
41
  <% next unless at %>
42
- <div><%= label %> <%= at.strftime("%H:%M") %> · <%= HrLite::OfficeLocation.place(lat, lng, @offices) || "No GPS" %>
42
+ <div><%= label %> <%= at.strftime("%H:%M") %> · <%= record.remote? ? record.work_mode_label : (HrLite::OfficeLocation.place(lat, lng, @offices) || "No GPS") %>
43
43
  <% if lat && lng %>
44
44
  · <a class="hrl-card__link" href="https://www.google.com/maps?q=<%= lat %>,<%= lng %>" target="_blank" rel="noopener noreferrer">Map<span class="hrl-sr-only"> of <%= label.downcase %> <%= record.date.strftime("%-d %b") %></span></a>
45
45
  <% end %>
@@ -58,6 +58,11 @@
58
58
  </div>
59
59
  <div class="hrl-field"><%= f.label :department %><%= f.text_field :department %></div>
60
60
  <div class="hrl-field"><%= f.label :work_location %><%= f.text_field :work_location %></div>
61
+ <% offices = HrLite::OfficeLocation.order(:name).pluck(:name, :id) %>
62
+ <% if offices.any? %>
63
+ <div class="hrl-field"><%= f.label :office_location_id, "Office (decides which office holidays apply)" %>
64
+ <%= f.select :office_location_id, offices, { include_blank: "None — only all-office holidays" }, data: { hrl_filter: ("Type an office" if offices.size > 5) } %></div>
65
+ <% end %>
61
66
  <div class="hrl-field"><%= f.label :date_of_birth %><%= f.date_field :date_of_birth %></div>
62
67
  <div class="hrl-field"><%= f.label :date_of_joining %><%= f.date_field :date_of_joining %></div>
63
68
  <div class="hrl-field">
@@ -85,6 +90,10 @@
85
90
  </div>
86
91
  <div class="hrl-field"><%= f.label :buffer_note, "Buffer note (HR only; required for Other)" %><%= f.text_area :buffer_note, rows: 2 %></div>
87
92
  <% end %>
93
+ <% if HrLite.config.office_geofence %>
94
+ <div class="hrl-field hrl-field--check"><%= f.check_box :permanent_wfh %><%= f.label :permanent_wfh, "Permanent work from home (every working day counts as approved WFH; check-in still within 500 m of their approved home location)" %></div>
95
+ <div class="hrl-field hrl-field--check"><%= f.check_box :field_staff %><%= f.label :field_staff, "Field staff (e.g. sales: may check in from anywhere as Travelling or Client meeting, with purpose and place)" %></div>
96
+ <% end %>
88
97
  <div class="hrl-field">
89
98
  <%= f.label :date_of_exit, "Date of exit (leave blank while employed)" %>
90
99
  <%= f.date_field :date_of_exit %>
@@ -11,12 +11,13 @@
11
11
  <% if @holidays.any? %>
12
12
  <div class="hrl-table-wrap">
13
13
  <table class="hrl-table hrl-table--stack">
14
- <thead><tr><th>Date</th><th>Holiday</th><th>Optional</th><th></th></tr></thead>
14
+ <thead><tr><th>Date</th><th>Holiday</th><th>Applies to</th><th>Optional</th><th></th></tr></thead>
15
15
  <tbody>
16
16
  <% @holidays.each do |holiday| %>
17
17
  <tr>
18
18
  <td data-label="Date"><%= holiday.date.strftime("%a, %d %b %Y") %></td>
19
19
  <td data-label="Holiday"><%= holiday.name %></td>
20
+ <td data-label="Applies to"><%= holiday.office_label %></td>
20
21
  <td data-label="Optional"><%= holiday.optional ? "Yes" : "No" %></td>
21
22
  <td data-label="">
22
23
  <%= button_to "Remove", hr_lite.admin_holiday_path(holiday), method: :delete,
@@ -39,6 +40,10 @@
39
40
  <%= form_with model: @holiday, url: hr_lite.admin_holidays_path, local: true do |f| %>
40
41
  <div class="hrl-field"><%= f.label :date %><%= f.date_field :date %></div>
41
42
  <div class="hrl-field"><%= f.label :name %><%= f.text_field :name %></div>
43
+ <% if @offices.any? %>
44
+ <div class="hrl-field"><%= f.label :office_location_id, "Applies to" %>
45
+ <%= f.select :office_location_id, @offices, { include_blank: "All offices" }, data: { hrl_filter: ("Type an office" if @offices.size > 5) } %></div>
46
+ <% end %>
42
47
  <div class="hrl-field hrl-field--check"><%= f.check_box :optional %><%= f.label :optional, "Optional / restricted" %></div>
43
48
  <div class="hrl-form-actions"><%= f.submit "Add holiday", class: "hrl-btn hrl-btn--primary" %></div>
44
49
  <% end %>
@@ -48,6 +53,10 @@
48
53
  <h2 class="hrl-card__title">Bulk paste</h2>
49
54
  <p class="hrl-small hrl-muted">One per line: <code>YYYY-MM-DD, Name</code> (append <code>, optional</code> for restricted holidays). Duplicates are skipped.</p>
50
55
  <%= form_with url: hr_lite.bulk_create_admin_holidays_path, method: :post, local: true do %>
56
+ <% if @offices.any? %>
57
+ <div class="hrl-field"><label for="bulk-office">Applies to</label>
58
+ <%= select_tag :office_location_id, options_for_select(@offices), include_blank: "All offices", id: "bulk-office", data: { hrl_filter: ("Type an office" if @offices.size > 5) } %></div>
59
+ <% end %>
51
60
  <div class="hrl-field">
52
61
  <textarea name="lines" class="hrl-input" rows="6" placeholder="2026-11-09, Diwali&#10;2026-03-04, Holi"></textarea>
53
62
  </div>
@@ -11,6 +11,15 @@
11
11
  <%= f.select :weekend_policy, HrLite::Setting::WEEKEND_POLICIES.map { |p| [ p.humanize, p ] } %>
12
12
  <p class="hrl-hint">Drives working-day math everywhere: leave counting, attendance, payroll.</p>
13
13
  </div>
14
+ <div class="hrl-field">
15
+ <%= f.label :lunch_break_minutes, "Lunch break limit (minutes)" %>
16
+ <%= f.number_field :lunch_break_minutes, min: 5, max: 120 %>
17
+ <p class="hrl-hint">One lunch a day; it ends by itself at this limit.</p>
18
+ </div>
19
+ <div class="hrl-field">
20
+ <%= f.label :short_break_minutes, "Short break limit (minutes)" %>
21
+ <%= f.number_field :short_break_minutes, min: 1, max: 60 %>
22
+ </div>
14
23
  <div class="hrl-field">
15
24
  <%= f.label :employee_code_prefix, "Employee code prefix" %>
16
25
  <%= f.text_field :employee_code_prefix %>
@@ -1,4 +1,4 @@
1
- <%# locals: active (:leaves | :comp_off | :regularizations | :balances) %>
1
+ <%# locals: active (:leaves | :comp_off | :regularizations | :wfh | :locations | :balances) %>
2
2
  <% tab = ->(key) { tag.attributes(class: [ "hrl-btn", ("hrl-btn--primary" if active == key) ], aria: { current: ("page" if active == key) }) } %>
3
3
  <div class="hrl-row hrl-row--mb hrl-row--tabs">
4
4
  <a <%= tab.(:leaves) %>
@@ -9,6 +9,12 @@
9
9
  <a <%= tab.(:regularizations) %>
10
10
  href="<%= hr_lite.admin_regularization_requests_path %>">Regularization
11
11
  <% if (n = HrLite::RegularizationRequest.pending.count).positive? %><span class="hrl-badge hrl-badge--warn"><%= n %></span><% end %></a>
12
+ <a <%= tab.(:wfh) %>
13
+ href="<%= hr_lite.admin_wfh_requests_path %>">WFH
14
+ <% if (n = HrLite::WfhRequest.pending.count).positive? %><span class="hrl-badge hrl-badge--warn"><%= n %></span><% end %></a>
15
+ <a <%= tab.(:locations) %>
16
+ href="<%= hr_lite.admin_work_locations_path %>">Locations
17
+ <% if (n = HrLite::WorkLocation.pending.count).positive? %><span class="hrl-badge hrl-badge--warn"><%= n %></span><% end %></a>
12
18
  <a <%= tab.(:balances) %>
13
19
  href="<%= hr_lite.admin_leave_balances_path %>">Balances</a>
14
20
  </div>
@@ -0,0 +1,91 @@
1
+ <% content_for(:page_title) { "WFH" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Work from home</h1>
4
+ </div>
5
+
6
+ <%= render "hr_lite/admin/shared/approvals_tabs", active: :wfh %>
7
+
8
+ <section class="hrl-card">
9
+ <%= form_with url: hr_lite.admin_wfh_requests_path, method: :get, local: true do %>
10
+ <div class="hrl-field"><label for="wfh-from">From</label><%= date_field_tag :from, @from, id: "wfh-from" %></div>
11
+ <div class="hrl-field"><label for="wfh-to">To</label><%= date_field_tag :to, @to, id: "wfh-to" %></div>
12
+ <div class="hrl-field"><label for="wfh-q">Employee name</label><%= search_field_tag :q, params[:q], id: "wfh-q" %></div>
13
+ <% if @departments.any? %>
14
+ <div class="hrl-field"><label for="wfh-dept">Department</label><%= select_tag :department, options_for_select(@departments, params[:department]), include_blank: "All", id: "wfh-dept" %></div>
15
+ <% end %>
16
+ <div class="hrl-field"><label for="wfh-status">Status</label><%= select_tag :status, options_for_select(HrLite::WfhRequest::STATUSES.map { |s| [ s.humanize, s ] }, @status), include_blank: "All", id: "wfh-status" %></div>
17
+ <div class="hrl-field"><label for="wfh-on">Who is home on</label><%= date_field_tag :on, @on, id: "wfh-on" %></div>
18
+ <div class="hrl-field"><label for="wfh-att">WFH attendance</label><%= select_tag :attendance, options_for_select([ [ "Checked in", "checked_in" ], [ "Not checked in", "not_checked_in" ] ], @attendance), include_blank: "All", id: "wfh-att" %></div>
19
+ <div class="hrl-form-actions">
20
+ <button type="submit" class="hrl-btn hrl-btn--primary">Filter</button>
21
+ <a class="hrl-btn" href="<%= hr_lite.admin_wfh_requests_path %>">Reset</a>
22
+ </div>
23
+ <% end %>
24
+ </section>
25
+
26
+ <div class="hrl-kpis">
27
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:requested] %></div><div class="hrl-kpi__label">Employees requested</div></div>
28
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:approved] %></div><div class="hrl-kpi__label">Employees approved</div></div>
29
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:pending] %></div><div class="hrl-kpi__label">Pending requests</div></div>
30
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:rejected] %></div><div class="hrl-kpi__label">Rejected requests</div></div>
31
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @home_count %></div><div class="hrl-kpi__label">Home on <%= @on.strftime("%-d %b") %></div></div>
32
+ </div>
33
+
34
+ <section class="hrl-card">
35
+ <h2 class="hrl-card__title">Working from home on <%= @on.strftime("%a %-d %b") %></h2>
36
+ <% if @home.any? %>
37
+ <% @home.each do |row| %>
38
+ <% rec = @records[row.user.id]
39
+ state, tone = rec ? (rec.work_mode == "wfh" ? [ "WFH check-in", "ok" ] : [ "#{rec.work_mode_label || 'Checked in'}", "muted" ]) : [ "Not checked in", "muted" ] %>
40
+ <div class="hrl-feed__item hrl-row">
41
+ <div class="hrl-feed__body"><strong><%= hr_display_name(row.user) %></strong> · <%= row.label %>
42
+ <span class="hrl-badge hrl-badge--<%= tone %>"><%= state %></span></div>
43
+ <% if row.request %>
44
+ <a class="hrl-btn" href="<%= hr_lite.admin_wfh_request_path(row.request) %>">Open<span class="hrl-sr-only"> <%= hr_display_name(row.user) %>'s request</span></a>
45
+ <% end %>
46
+ </div>
47
+ <% end %>
48
+ <% else %>
49
+ <p class="hrl-card--empty">Nobody is working from home on this date.</p>
50
+ <% end %>
51
+ </section>
52
+
53
+ <% if @grantable.any? %>
54
+ <section class="hrl-card">
55
+ <h2 class="hrl-card__title">Give WFH</h2>
56
+ <p class="hrl-small hrl-muted">Approved straight away, up to <%= HrLite::WfhRequest::HR_MAX_DAYS %> days from today or later.</p>
57
+ <%= form_with url: hr_lite.admin_wfh_requests_path, method: :post, local: true do %>
58
+ <div class="hrl-field"><label for="grant-user">Employee</label><%= select_tag :user_id, options_for_select(@grantable.map { |u| [ hr_display_name(u), u.id ] }), include_blank: "Choose one", required: true, id: "grant-user", data: { hrl_filter: ("Type a name to filter" if @grantable.size > 5) } %></div>
59
+ <div class="hrl-field"><label for="grant-from">From</label><%= date_field_tag :start_date, Date.current, min: Date.current, required: true, id: "grant-from" %></div>
60
+ <div class="hrl-field"><label for="grant-to">To</label><%= date_field_tag :end_date, Date.current, min: Date.current, required: true, id: "grant-to" %></div>
61
+ <div class="hrl-field"><label for="grant-reason">Reason</label><%= text_area_tag :reason, nil, rows: 2, required: true, id: "grant-reason" %></div>
62
+ <div class="hrl-form-actions"><button type="submit" class="hrl-btn hrl-btn--primary">Give WFH</button></div>
63
+ <% end %>
64
+ </section>
65
+ <% end %>
66
+
67
+ <section class="hrl-card">
68
+ <h2 class="hrl-card__title">Schedules <%= @from.strftime("%-d %b") %> – <%= @to.strftime("%-d %b") %></h2>
69
+ <% if @requests.any? %>
70
+ <div class="hrl-table-wrap">
71
+ <table class="hrl-table hrl-table--stack">
72
+ <thead><tr><th>Employee</th><th>Dates</th><th>Reason</th><th>Submitted</th><th>Status</th><th></th></tr></thead>
73
+ <tbody>
74
+ <% @requests.each do |request| %>
75
+ <tr>
76
+ <td data-label="Employee"><%= hr_display_name(request.user) %></td>
77
+ <td data-label="Dates"><%= request.dates_label %> <span class="hrl-small hrl-muted">· <%= pluralize(request.days, "day") %></span></td>
78
+ <td data-label="Reason"><%= truncate(request.reason, length: 80) %></td>
79
+ <td data-label="Submitted"><%= request.created_at.strftime("%-d %b") %></td>
80
+ <td data-label="Status"><%= hrl_request_status_badge(request.status) %></td>
81
+ <td data-label=""><a class="hrl-btn" href="<%= hr_lite.admin_wfh_request_path(request) %>">Open</a></td>
82
+ </tr>
83
+ <% end %>
84
+ </tbody>
85
+ </table>
86
+ </div>
87
+ <%= hrl_pagination %>
88
+ <% else %>
89
+ <p class="hrl-card--empty">No WFH requests match.</p>
90
+ <% end %>
91
+ </section>
@@ -0,0 +1,40 @@
1
+ <% content_for(:page_title) { "WFH — #{hr_display_name(@request.user)}" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">WFH — <%= hr_display_name(@request.user) %></h1>
4
+ <div class="hrl-page__actions">
5
+ <a class="hrl-btn" href="<%= hr_lite.admin_wfh_requests_path %>">All WFH</a>
6
+ <a class="hrl-btn" href="<%= hr_lite.admin_attendance_path(@request.user_id, month: @request.start_date.strftime("%Y-%m")) %>">Their month</a>
7
+ </div>
8
+ </div>
9
+
10
+ <section class="hrl-card">
11
+ <dl class="hrl-deflist">
12
+ <dt>Dates</dt><dd><%= @request.dates_label %> (<%= pluralize(@request.days, "day") %>)</dd>
13
+ <dt>Reason</dt><dd><%= @request.reason %></dd>
14
+ <dt>Submitted</dt><dd><%= @request.created_at.strftime("%d %b %Y %H:%M") %></dd>
15
+ <dt>Status</dt><dd><%= hrl_request_status_badge(@request.status) %></dd>
16
+ <% if @request.decided_at %>
17
+ <dt>Decided</dt>
18
+ <dd><%= hr_display_name(@request.decided_by) %> · <%= @request.decided_at.strftime("%d %b %H:%M") %>
19
+ <% if @request.decision_note.present? %> · <%= @request.decision_note %><% end %></dd>
20
+ <% end %>
21
+ </dl>
22
+ </section>
23
+
24
+ <% if @request.pending? && @decidable %>
25
+ <%# Two forms: Rails binds the CSRF token to each form's own action. %>
26
+ <section class="hrl-card">
27
+ <h2 class="hrl-card__title">Approve</h2>
28
+ <%= form_with url: hr_lite.approve_admin_wfh_request_path(@request), method: :post, local: true do |f| %>
29
+ <div class="hrl-field"><%= f.label :decision_note, "Remark (optional)", for: "approve_note" %><%= f.text_area :decision_note, id: "approve_note" %></div>
30
+ <div class="hrl-form-actions"><%= f.button "Approve WFH", type: :submit, class: "hrl-btn hrl-btn--primary" %></div>
31
+ <% end %>
32
+ </section>
33
+ <section class="hrl-card">
34
+ <h2 class="hrl-card__title">Reject</h2>
35
+ <%= form_with url: hr_lite.reject_admin_wfh_request_path(@request), method: :post, local: true do |f| %>
36
+ <div class="hrl-field"><%= f.label :decision_note, "Remark (required)", for: "reject_note" %><%= f.text_area :decision_note, id: "reject_note" %></div>
37
+ <div class="hrl-form-actions"><%= f.button "Reject", type: :submit, class: "hrl-btn hrl-btn--danger" %></div>
38
+ <% end %>
39
+ </section>
40
+ <% end %>
@@ -0,0 +1,42 @@
1
+ <% content_for(:page_title) { "WFH locations" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">WFH locations</h1>
4
+ </div>
5
+
6
+ <%= render "hr_lite/admin/shared/approvals_tabs", active: :locations %>
7
+
8
+ <div class="hrl-row hrl-row--mb">
9
+ <span class="hrl-small hrl-muted">Status</span>
10
+ <% HrLite::WorkLocation::STATUSES.each do |status| %>
11
+ <a class="hrl-btn <%= 'hrl-btn--primary' if @status == status %>" href="<%= hr_lite.admin_work_locations_path(status: status) %>"><%= status.humanize %></a>
12
+ <% end %>
13
+ </div>
14
+
15
+ <section class="hrl-card">
16
+ <% if @locations.any? %>
17
+ <% @locations.each do |location| %>
18
+ <div class="hrl-feed__item">
19
+ <div class="hrl-feed__body">
20
+ <strong><%= hr_display_name(location.user) %></strong> · <%= location.name %> <%= hrl_request_status_badge(location.status) %>
21
+ <div class="hrl-small hrl-muted"><%= location.address.presence || "No address given" %> · ±<%= location.accuracy_m %> m ·
22
+ <a class="hrl-card__link" href="<%= location.map_url %>" target="_blank" rel="noopener noreferrer">Map<span class="hrl-sr-only"> of <%= location.name %></span></a>
23
+ · registered <%= location.created_at.strftime("%-d %b %H:%M") %></div>
24
+ <% if location.decision_note.present? %><div class="hrl-small hrl-muted">Note: <%= location.decision_note %></div><% end %>
25
+ </div>
26
+ <% if location.pending? && location.user_id != hr_current_user.id %>
27
+ <%= form_with url: hr_lite.approve_admin_work_location_path(location), method: :post, local: true, class: "hrl-row" do %>
28
+ <button type="submit" class="hrl-btn hrl-btn--primary">Approve<span class="hrl-sr-only"> <%= location.name %></span></button>
29
+ <% end %>
30
+ <%= form_with url: hr_lite.reject_admin_work_location_path(location), method: :post, local: true, class: "hrl-row" do %>
31
+ <label class="hrl-sr-only" for="reject-loc-<%= location.id %>">Reason to reject</label>
32
+ <input type="text" class="hrl-input" name="decision_note" id="reject-loc-<%= location.id %>" required placeholder="Reason to reject">
33
+ <button type="submit" class="hrl-btn hrl-btn--danger">Reject</button>
34
+ <% end %>
35
+ <% end %>
36
+ </div>
37
+ <% end %>
38
+ <%= hrl_pagination %>
39
+ <% else %>
40
+ <p class="hrl-card--empty">No <%= @status %> locations.</p>
41
+ <% end %>
42
+ </section>
@@ -0,0 +1,105 @@
1
+ <% content_for(:page_title) { "Work modes" } %>
2
+ <% live = @date == Date.current
3
+ modes = HrLite::WorkModeEvent::MODES.map { |k, v| [ k, v[0] ] } + HrLite::WorkStatus::DERIVED.except("offline").to_a %>
4
+ <div class="hrl-page__head">
5
+ <h1 class="hrl-page__title">Work modes — <%= live ? "now" : @date.strftime("%a %-d %b") %></h1>
6
+ <div class="hrl-page__actions">
7
+ <a class="hrl-btn" href="<%= hr_lite.admin_work_modes_path(date: @date, department: "Sales") %>">Sales team</a>
8
+ </div>
9
+ </div>
10
+
11
+ <section class="hrl-card">
12
+ <%= form_with url: hr_lite.admin_work_modes_path, method: :get, local: true, class: "hrl-row" do %>
13
+ <div class="hrl-field"><label for="wm-date">Date</label><%= date_field_tag :date, @date, max: Date.current, id: "wm-date" %></div>
14
+ <% if @departments.any? %>
15
+ <div class="hrl-field"><label for="wm-dept">Department</label><%= select_tag :department, options_for_select(@departments, params[:department]), include_blank: "All", id: "wm-dept" %></div>
16
+ <% end %>
17
+ <div class="hrl-form-actions"><button type="submit" class="hrl-btn">Show</button></div>
18
+ <% end %>
19
+ <p class="hrl-small hrl-muted"><%= live ? "Live status. A person can count twice when a break or meeting sits on top of their work mode." : "Everyone who spent any time in each mode that day." %></p>
20
+ </section>
21
+
22
+ <div class="hrl-kpis">
23
+ <% modes.each do |mode, label| %>
24
+ <% next unless @by_mode[mode]&.any? %>
25
+ <a class="hrl-kpi" href="#mode-<%= mode %>"><div class="hrl-kpi__value"><%= @by_mode[mode].size %></div>
26
+ <div class="hrl-kpi__label"><%= hrl_mode_icon(HrLite::WorkStatus::Entry.new(mode: mode, label: label)) %> <%= label %></div></a>
27
+ <% end %>
28
+ <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @needs_review.size %></div><div class="hrl-kpi__label">Needs review</div></div>
29
+ </div>
30
+
31
+ <% if @needs_review.any? %>
32
+ <section class="hrl-card">
33
+ <h2 class="hrl-card__title">Needs review — working day, no leave, no check-in</h2>
34
+ <% @needs_review.each do |id| %>
35
+ <div class="hrl-feed__item hrl-row"><div class="hrl-feed__body"><%= hr_display_name(@people[id]) %></div>
36
+ <a class="hrl-btn" href="<%= hr_lite.admin_attendance_path(id, month: @date.strftime("%Y-%m")) %>">Their month</a></div>
37
+ <% end %>
38
+ </section>
39
+ <% end %>
40
+
41
+ <% if @field.any? %>
42
+ <section class="hrl-card">
43
+ <h2 class="hrl-card__title">Field check-ins</h2>
44
+ <% @field.each do |record| %>
45
+ <% event = @field_events[record.id] %>
46
+ <div class="hrl-feed__item hrl-small"><div class="hrl-feed__body">
47
+ <strong><%= hr_display_name(@people[record.user_id]) %></strong> · <%= record.work_mode_label %> · <%= record.check_in_at.strftime("%H:%M") %>
48
+ <% if event %> · <%= [ event.title, event.client, event.destination ].compact_blank.join(" · ") %><% end %>
49
+ <% if record.check_in_lat %> · <a class="hrl-card__link" href="https://www.google.com/maps?q=<%= record.check_in_lat %>,<%= record.check_in_lng %>" target="_blank" rel="noopener noreferrer">Map</a><% end %>
50
+ </div></div>
51
+ <% end %>
52
+ </section>
53
+ <% end %>
54
+
55
+ <% modes.each do |mode, label| %>
56
+ <% next unless @by_mode[mode]&.any? %>
57
+ <section class="hrl-card" id="mode-<%= mode %>">
58
+ <h2 class="hrl-card__title"><%= label %></h2>
59
+ <% @by_mode[mode].each do |id| %>
60
+ <div class="hrl-feed__item hrl-row"><div class="hrl-feed__body"><%= hr_display_name(@people[id]) %></div>
61
+ <a class="hrl-btn" href="<%= hr_lite.admin_work_modes_path(date: @date, department: params[:department], user_id: id, anchor: "timeline") %>">Timeline<span class="hrl-sr-only"> for <%= hr_display_name(@people[id]) %></span></a></div>
62
+ <% end %>
63
+ </section>
64
+ <% end %>
65
+
66
+ <% if @person %>
67
+ <section class="hrl-card" id="timeline">
68
+ <h2 class="hrl-card__title"><%= hr_display_name(@person) %> — <%= @date.strftime("%a %-d %b") %></h2>
69
+ <% if @timeline.any? %>
70
+ <% @timeline.each do |event| %>
71
+ <% ended = event.ended_at || (event.ends_at if event.expired?) %>
72
+ <div class="hrl-feed__item hrl-small"><div class="hrl-feed__body">
73
+ <%= hrl_mode_icon(HrLite::WorkStatus::Entry.new(mode: event.mode, label: event.label, source: event.source)) %>
74
+ <strong><%= event.started_at.strftime("%H:%M") %>–<%= ended&.strftime("%H:%M") || "now" %></strong> <%= event.label %>
75
+ · <%= hrl_duration((ended || Time.current) - event.started_at) %>
76
+ · <%= HrLite::ApplicationHelper::MODE_SOURCES[event.source] %><% if event.set_by && event.source == "hr" %> (<%= hr_display_name(event.set_by) %>)<% end %>
77
+ <% details = [ event.emoji, event.title, event.client, event.destination, (@locations[event.work_location_id]&.name if event.work_location_id), event.reason ].compact_blank %>
78
+ <% if details.any? %><div class="hrl-muted"><%= details.join(" · ") %></div><% end %>
79
+ <% if event.crm_ref && (lead = HrLite.config.crm_find&.call(event.crm_ref)) %>
80
+ <a class="hrl-card__link" href="<%= lead[:url] %>">Lead: <%= lead[:label] %></a>
81
+ <% end %>
82
+ <% if event.overrun %><span class="hrl-badge hrl-badge--warn">Ended at the limit</span><% end %>
83
+ </div></div>
84
+ <% end %>
85
+ <% else %>
86
+ <p class="hrl-card--empty">No mode changes recorded that day.</p>
87
+ <% end %>
88
+
89
+ <% if live && @person.id != hr_current_user.id %>
90
+ <details>
91
+ <summary class="hrl-btn">Set their mode</summary>
92
+ <%= form_with url: hr_lite.admin_work_mode_override_path(@person.id), method: :post, local: true do %>
93
+ <div class="hrl-field"><label for="ov-mode">Mode</label><%= select_tag :mode, options_for_select(HrLite::WorkModeSwitch::SELECTABLE.map { |m| [ HrLite::WorkModeEvent.label(m), m ] }), id: "ov-mode" %></div>
94
+ <div class="hrl-field"><label for="ov-title">Title / purpose (meeting, travel, client meeting)</label><input class="hrl-input" id="ov-title" name="title" maxlength="120"></div>
95
+ <div class="hrl-field"><label for="ov-client">Client</label><input class="hrl-input" id="ov-client" name="client" maxlength="120"></div>
96
+ <div class="hrl-field"><label for="ov-dest">Destination</label><input class="hrl-input" id="ov-dest" name="destination" maxlength="120"></div>
97
+ <div class="hrl-field"><label for="ov-cat">Out-of-office reason</label><%= select_tag :category, options_for_select(HrLite::WorkModeSwitch::OUT_OF_OFFICE_REASONS.invert), include_blank: "—", id: "ov-cat" %></div>
98
+ <div class="hrl-field"><label for="ov-until">Ends at</label><input class="hrl-input" type="time" id="ov-until" name="until"></div>
99
+ <div class="hrl-field"><label for="ov-reason">Why HR is setting it (required)</label><input class="hrl-input" id="ov-reason" name="reason" maxlength="200" required></div>
100
+ <button type="submit" class="hrl-btn hrl-btn--primary">Set mode</button>
101
+ <% end %>
102
+ </details>
103
+ <% end %>
104
+ </section>
105
+ <% end %>