hr_lite 0.31.0 → 0.35.1

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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -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 +59 -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/work_locations_controller.rb +41 -0
  13. data/app/controllers/hr_lite/admin/work_modes_controller.rb +72 -0
  14. data/app/controllers/hr_lite/admin/work_time_controller.rb +39 -0
  15. data/app/controllers/hr_lite/attendance_controller.rb +2 -1
  16. data/app/controllers/hr_lite/calendar_controller.rb +3 -2
  17. data/app/controllers/hr_lite/comp_off_requests_controller.rb +1 -1
  18. data/app/controllers/hr_lite/holidays_controller.rb +3 -2
  19. data/app/controllers/hr_lite/team_controller.rb +13 -0
  20. data/app/controllers/hr_lite/work_locations_controller.rb +44 -0
  21. data/app/controllers/hr_lite/work_modes_controller.rb +40 -0
  22. data/app/controllers/hr_lite/work_time_controller.rb +14 -0
  23. data/app/helpers/hr_lite/application_helper.rb +43 -2
  24. data/app/jobs/hr_lite/attendance_close_job.rb +12 -5
  25. data/app/jobs/hr_lite/calendar_meeting_start_job.rb +31 -0
  26. data/app/jobs/hr_lite/calendar_meeting_sync_job.rb +55 -0
  27. data/app/jobs/hr_lite/work_mode_expire_job.rb +12 -0
  28. data/app/models/hr_lite/attendance_penalty.rb +2 -2
  29. data/app/models/hr_lite/attendance_record.rb +7 -0
  30. data/app/models/hr_lite/comp_off_request.rb +2 -2
  31. data/app/models/hr_lite/employee_profile.rb +2 -0
  32. data/app/models/hr_lite/holiday.rb +11 -5
  33. data/app/models/hr_lite/leave_balance.rb +1 -1
  34. data/app/models/hr_lite/leave_request.rb +1 -1
  35. data/app/models/hr_lite/regularization_request.rb +1 -1
  36. data/app/models/hr_lite/setting.rb +2 -0
  37. data/app/models/hr_lite/work_location.rb +62 -0
  38. data/app/models/hr_lite/work_mode_event.rb +84 -0
  39. data/app/services/hr_lite/attendance_puncher.rb +60 -19
  40. data/app/services/hr_lite/day_status.rb +1 -1
  41. data/app/services/hr_lite/leave_day_counter.rb +5 -7
  42. data/app/services/hr_lite/team_day.rb +6 -6
  43. data/app/services/hr_lite/today_checkins.rb +41 -0
  44. data/app/services/hr_lite/work_mode_durations.rb +237 -0
  45. data/app/services/hr_lite/work_mode_switch.rb +144 -0
  46. data/app/services/hr_lite/work_status.rb +60 -0
  47. data/app/services/hr_lite/working_calendar.rb +11 -2
  48. data/app/views/hr_lite/admin/attendances/show.html.erb +1 -1
  49. data/app/views/hr_lite/admin/employees/_form.html.erb +7 -1
  50. data/app/views/hr_lite/admin/holidays/index.html.erb +10 -1
  51. data/app/views/hr_lite/admin/settings/edit.html.erb +9 -0
  52. data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +4 -1
  53. data/app/views/hr_lite/admin/wfh_requests/index.html.erb +2 -2
  54. data/app/views/hr_lite/admin/work_locations/index.html.erb +42 -0
  55. data/app/views/hr_lite/admin/work_modes/index.html.erb +105 -0
  56. data/app/views/hr_lite/admin/work_time/index.html.erb +42 -0
  57. data/app/views/hr_lite/attendance/_calendar_connection.html.erb +15 -0
  58. data/app/views/hr_lite/attendance/_crm_picker.html.erb +9 -0
  59. data/app/views/hr_lite/attendance/_custom_status.html.erb +26 -0
  60. data/app/views/hr_lite/attendance/_punch_card.html.erb +26 -6
  61. data/app/views/hr_lite/attendance/_work_mode.html.erb +92 -0
  62. data/app/views/hr_lite/attendance/show.html.erb +4 -1
  63. data/app/views/hr_lite/holidays/_next_holiday.html.erb +1 -1
  64. data/app/views/hr_lite/home/_today_checkins.html.erb +31 -0
  65. data/app/views/hr_lite/home/index.html.erb +5 -0
  66. data/app/views/hr_lite/team/show.html.erb +25 -4
  67. data/app/views/hr_lite/work_locations/index.html.erb +52 -0
  68. data/app/views/hr_lite/work_time/_bars.html.erb +44 -0
  69. data/app/views/hr_lite/work_time/_donut.html.erb +36 -0
  70. data/app/views/hr_lite/work_time/_report.html.erb +74 -0
  71. data/app/views/hr_lite/work_time/_timeline.html.erb +26 -0
  72. data/app/views/hr_lite/work_time/show.html.erb +10 -0
  73. data/config/routes.rb +16 -0
  74. data/db/migrate/20261013110000_create_hr_lite_work_locations.rb +23 -0
  75. data/db/migrate/20261013110100_add_work_mode_to_hr_lite_attendance_records.rb +7 -0
  76. data/db/migrate/20261013110200_add_field_staff_to_hr_lite_employee_profiles.rb +5 -0
  77. data/db/migrate/20261013110300_create_hr_lite_work_mode_events.rb +30 -0
  78. data/db/migrate/20261013110400_add_break_minutes_to_hr_lite_settings.rb +6 -0
  79. data/db/migrate/20261013120000_grant_hr_lite_work_analytics.rb +14 -0
  80. data/db/migrate/20261013130000_add_office_location_to_hr_lite_holidays.rb +12 -0
  81. data/db/migrate/20261013130100_add_office_location_to_hr_lite_employee_profiles.rb +6 -0
  82. data/db/migrate/20261013140000_add_calendar_and_crm_to_hr_lite_work_mode_events.rb +9 -0
  83. data/db/migrate/20261013140100_add_custom_status_to_hr_lite_work_mode_events.rb +9 -0
  84. data/lib/hr_lite/configuration.rb +13 -1
  85. data/lib/hr_lite/notifications.rb +9 -4
  86. data/lib/hr_lite/permissions.rb +1 -0
  87. data/lib/hr_lite/role_seeds.rb +4 -2
  88. data/lib/hr_lite/seeds.rb +1 -1
  89. data/lib/hr_lite/version.rb +1 -1
  90. metadata +44 -1
@@ -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") %> · <%= record.wfh ? "WFH" : (HrLite::OfficeLocation.place(lat, lng, @offices) || "No GPS") %>
42
+ <div class="hrl-punch-line"><%= 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">
@@ -86,7 +91,8 @@
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 %>
88
93
  <% if HrLite.config.office_geofence %>
89
- <div class="hrl-field hrl-field--check"><%= f.check_box :permanent_wfh %><%= f.label :permanent_wfh, "Permanent work from home (checks in from anywhere; inside the office it counts as office)" %></div>
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>
90
96
  <% end %>
91
97
  <div class="hrl-field">
92
98
  <%= f.label :date_of_exit, "Date of exit (leave blank while employed)" %>
@@ -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 | :wfh | :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) %>
@@ -12,6 +12,9 @@
12
12
  <a <%= tab.(:wfh) %>
13
13
  href="<%= hr_lite.admin_wfh_requests_path %>">WFH
14
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>
15
18
  <a <%= tab.(:balances) %>
16
19
  href="<%= hr_lite.admin_leave_balances_path %>">Balances</a>
17
20
  </div>
@@ -36,7 +36,7 @@
36
36
  <% if @home.any? %>
37
37
  <% @home.each do |row| %>
38
38
  <% rec = @records[row.user.id]
39
- state, tone = rec ? (rec.wfh ? [ "WFH check-in", "ok" ] : [ "In office", "muted" ]) : [ "Not checked in", "muted" ] %>
39
+ state, tone = rec ? (rec.work_mode == "wfh" ? [ "WFH check-in", "ok" ] : [ "#{rec.work_mode_label || 'Checked in'}", "muted" ]) : [ "Not checked in", "muted" ] %>
40
40
  <div class="hrl-feed__item hrl-row">
41
41
  <div class="hrl-feed__body"><strong><%= hr_display_name(row.user) %></strong> · <%= row.label %>
42
42
  <span class="hrl-badge hrl-badge--<%= tone %>"><%= state %></span></div>
@@ -55,7 +55,7 @@
55
55
  <h2 class="hrl-card__title">Give WFH</h2>
56
56
  <p class="hrl-small hrl-muted">Approved straight away, up to <%= HrLite::WfhRequest::HR_MAX_DAYS %> days from today or later.</p>
57
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" %></div>
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
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
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
61
  <div class="hrl-field"><label for="grant-reason">Reason</label><%= text_area_tag :reason, nil, rows: 2, required: true, id: "grant-reason" %></div>
@@ -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 %>
@@ -0,0 +1,42 @@
1
+ <% content_for(:page_title) { "Work time" } %>
2
+ <div class="hrl-page__head">
3
+ <h1 class="hrl-page__title">Work time<%= " — #{@names[@person.id]}" if @person %></h1>
4
+ <% if @person %>
5
+ <div class="hrl-page__actions">
6
+ <a class="hrl-btn" href="<%= hr_lite.admin_work_modes_path(date: @period.to, user_id: @person.id, anchor: "timeline") %>">Day timeline</a>
7
+ </div>
8
+ <% end %>
9
+ </div>
10
+
11
+ <section class="hrl-card">
12
+ <%= form_with url: hr_lite.admin_work_time_path, method: :get, local: true, class: "hrl-row" do %>
13
+ <input type="hidden" name="period" value="<%= @period.kind %>">
14
+ <input type="hidden" name="date" value="<%= @period.from %>">
15
+ <% if @period.kind == "custom" %><input type="hidden" name="from" value="<%= @period.from %>"><input type="hidden" name="to" value="<%= @period.to %>"><% end %>
16
+ <div class="hrl-field"><label for="wt-user">Employee</label>
17
+ <%= select_tag :user_id, options_for_select(@employees.map { |u| [ @names[u.id], u.id ] }, @person&.id), include_blank: "Everyone (totals only)", id: "wt-user", data: { hrl_filter: ("Type a name to filter" if @employees.size > 5) } %></div>
18
+ <% if @departments.any? %>
19
+ <div class="hrl-field"><label for="wt-dept">Department</label><%= select_tag :department, options_for_select(@departments, params[:department]), include_blank: "All", id: "wt-dept" %></div>
20
+ <% end %>
21
+ <div class="hrl-form-actions"><button type="submit" class="hrl-btn hrl-btn--primary">Show</button></div>
22
+ <% end %>
23
+ <p class="hrl-small hrl-muted"><%= @person ? "Opening one person's work time is recorded in the audit trail." : "Totals across #{pluralize(@employees.size, 'person')}; pick someone for their own breakdown." %></p>
24
+ </section>
25
+
26
+ <% if @period_error %><div class="hrl-errors" role="alert"><%= @period_error %></div><% end %>
27
+
28
+ <% keep = { user_id: @person&.id, department: params[:department] }.compact %>
29
+ <%= render "hr_lite/work_time/report", result: @result, period: @period, records: @records, keep: keep,
30
+ names: (@names if @person), timeline: @person.present?, path: ->(p) { hr_lite.admin_work_time_path(keep.merge(p)) } %>
31
+
32
+ <% if @result.gaps.any? %>
33
+ <section class="hrl-card">
34
+ <h2 class="hrl-card__title">Worth a look</h2>
35
+ <% @result.gaps.sort_by { |_, date, _| date }.first(200).each do |user_id, date, reason| %>
36
+ <div class="hrl-feed__item hrl-row hrl-small"><div class="hrl-feed__body"><strong><%= @names[user_id] %></strong> · <%= date.strftime("%a %-d %b") %> · <%= reason %></div>
37
+ <a class="hrl-btn" href="<%= hr_lite.admin_attendance_path(user_id, month: date.strftime("%Y-%m"), date: date, anchor: "regularize") %>">Open day</a></div>
38
+ <% end %>
39
+ </section>
40
+ <% end %>
41
+
42
+ <% content_for :scripts do %><%= javascript_include_tag "hr_lite/chart_tip", defer: true %><% end %>
@@ -0,0 +1,15 @@
1
+ <%# One-click link to the person's own calendar (config.calendar_connection). %>
2
+ <% link = HrLite.config.calendar_connection&.call(hr_current_user) %>
3
+ <% if link %>
4
+ <div class="hrl-row hrl-small">
5
+ <% if link[:connected] %>
6
+ <span class="hrl-badge hrl-badge--ok">Calendar connected</span>
7
+ <span class="hrl-muted"><%= link[:label] %> · meetings set your status by themselves</span>
8
+ <%= button_to "Disconnect", link[:disconnect_url], method: :delete, class: "hrl-btn",
9
+ form: { data: { turbo_confirm: "Stop reading your calendar?" } } %>
10
+ <% else %>
11
+ <a class="hrl-btn hrl-btn--block" href="<%= link[:connect_url] %>">Connect Google Calendar</a>
12
+ <span class="hrl-muted">One tap: meetings on your calendar then set your status to Meeting by themselves.</span>
13
+ <% end %>
14
+ </div>
15
+ <% end %>
@@ -0,0 +1,9 @@
1
+ <%# A lead from the CRM, searched as you type (config.crm_search). Optional. %>
2
+ <% if HrLite.config.crm_search %>
3
+ <div class="hrl-field" data-hrl-crm="<%= hr_lite.crm_search_work_mode_path %>">
4
+ <label for="<%= id %>-q">Link a lead (optional)</label>
5
+ <input type="search" class="hrl-input" id="<%= id %>-q" placeholder="Type a name or phone" autocomplete="off">
6
+ <label class="hrl-sr-only" for="<%= id %>">Lead</label>
7
+ <select name="crm_ref" id="<%= id %>"><option value="">No lead</option></select>
8
+ </div>
9
+ <% end %>
@@ -0,0 +1,26 @@
1
+ <%# The person's own status (text + emoji), Slack-style. Independent of check-in. %>
2
+ <% custom = status.custom
3
+ picks = %w[🏠 🏢 ✈️ 🚗 🚆 🏖️ 🤒 🤧 🩺 🍽️ ☕ 🎧 🎯 💻 📞 📅 🗓️ 🤝 💼 📍 🧳 🏔️ 🌴 🏕️ 📝 📊 🔒 🚫 ⏳ 🔕 🎉 🙏 👶 👨‍👩‍👧 🏥 📚 🎤 🛠️ 🌙 🌧️] %>
4
+ <% if custom %>
5
+ <div class="hrl-row hrl-row--mb hrl-small">
6
+ <span class="hrl-custom-status"><% if custom.emoji %><span aria-hidden="true"><%= custom.emoji %></span> <% end %><%= custom.title || "Status" %></span>
7
+ <% if custom.ends_at %><span class="hrl-muted">until <%= custom.ends_at.strftime(custom.ends_at.to_date == Date.current ? "%H:%M" : "%-d %b %H:%M") %></span><% end %>
8
+ <%= button_to "Clear status", hr_lite.clear_status_work_mode_path, method: :post, class: "hrl-btn" %>
9
+ </div>
10
+ <% end %>
11
+ <details>
12
+ <summary class="hrl-btn hrl-btn--block"><%= custom ? "Change my status" : "Set a status" %></summary>
13
+ <%= form_with url: hr_lite.status_work_mode_path, method: :post, local: true, data: { "hrl-emoji-form": true } do %>
14
+ <div class="hrl-field"><label for="st-emoji">Emoji</label>
15
+ <input class="hrl-input" id="st-emoji" name="emoji" maxlength="16" value="<%= custom&.emoji %>" placeholder="Pick or type one" autocomplete="off">
16
+ <div class="hrl-emoji-grid" role="group" aria-label="Pick an emoji">
17
+ <% picks.each do |e| %><button type="button" class="hrl-emoji" data-hrl-emoji="<%= e %>" aria-label="Use <%= e %>"><%= e %></button><% end %>
18
+ </div>
19
+ </div>
20
+ <div class="hrl-field"><label for="st-text">What's your status?</label>
21
+ <input class="hrl-input" id="st-text" name="text" maxlength="100" value="<%= custom&.title %>" placeholder="e.g. Visiting the Ladakh camp"></div>
22
+ <div class="hrl-field"><label for="st-clear">Clear after</label>
23
+ <%= select_tag :clear_after, options_for_select([ [ "30 minutes", "30" ], [ "1 hour", "60" ], [ "4 hours", "240" ], [ "Today", "today" ], [ "Don't clear", "never" ] ], "today"), id: "st-clear" %></div>
24
+ <button type="submit" class="hrl-btn hrl-btn--primary">Save status</button>
25
+ <% end %>
26
+ </details>
@@ -18,8 +18,8 @@
18
18
  <% if record&.check_out_at %>
19
19
  <span class="hrl-badge hrl-badge--muted">Out <%= record.check_out_at.strftime("%H:%M") %></span>
20
20
  <% end %>
21
- <% if record&.wfh %>
22
- <span class="hrl-badge hrl-badge--ok">WFH</span>
21
+ <% if record&.work_mode_label %>
22
+ <span class="hrl-badge hrl-badge--ok"><%= record.work_mode_label %></span>
23
23
  <% end %>
24
24
  <% if record&.flagged? %>
25
25
  <span class="hrl-badge hrl-badge--bad">Flagged</span>
@@ -34,6 +34,9 @@
34
34
  <% if HrLite.config.attendance_slots %>
35
35
  <%= render "hr_lite/attendance/slot", record: record %>
36
36
  <% end %>
37
+ <% if HrLite.config.office_geofence %>
38
+ <%= render "hr_lite/attendance/work_mode", record: record %>
39
+ <% end %>
37
40
  <% if record&.flagged? %>
38
41
  <p class="hrl-small">Flagged: <%= record.flag_note %></p>
39
42
  <% end %>
@@ -53,6 +56,18 @@
53
56
  html: { data: { "hrl-geo-punch": true, "hrl-geo-required": HrLite.config.require_punch_location.presence } } do %>
54
57
  <input type="hidden" name="lat"><input type="hidden" name="lng">
55
58
  <input type="hidden" name="accuracy_m"><input type="hidden" name="geo_status">
59
+ <% if HrLite.config.office_geofence && HrLite::EmployeeProfile.where(user_id: hr_current_user.id).pick(:field_staff) %>
60
+ <details>
61
+ <summary class="hrl-btn hrl-btn--block">Checking in from the field?</summary>
62
+ <fieldset class="hrl-field">
63
+ <legend class="hrl-small">Where are you?</legend>
64
+ <label class="hrl-field--check"><input type="radio" name="field[mode]" value="travelling"> Travelling</label>
65
+ <label class="hrl-field--check"><input type="radio" name="field[mode]" value="outside_meeting"> Client meeting</label>
66
+ </fieldset>
67
+ <div class="hrl-field"><label for="field-purpose">Purpose</label><input type="text" class="hrl-input" id="field-purpose" name="field[purpose]" maxlength="120"></div>
68
+ <div class="hrl-field"><label for="field-place">Destination or client</label><input type="text" class="hrl-input" id="field-place" name="field[place]" maxlength="120"></div>
69
+ </details>
70
+ <% end %>
56
71
  <button type="submit" class="hrl-btn hrl-btn--primary hrl-btn--big hrl-btn--block">Check in</button>
57
72
  <p class="hrl-small hrl-muted" data-hrl-geo-status role="status" aria-live="polite"></p>
58
73
  <% end %>
@@ -76,10 +91,15 @@
76
91
  </div>
77
92
  <% end %>
78
93
 
79
- <a class="hrl-card__link" href="<%= hr_lite.attendance_path %>">View my month</a>
80
- <% if HrLite.config.office_geofence %>
81
- · <a class="hrl-card__link" href="<%= hr_lite.wfh_requests_path %>">Work from home</a>
82
- <% end %>
94
+ <%# Links wrap as whole items; no separator dots left dangling at a line end. %>
95
+ <div class="hrl-links">
96
+ <a class="hrl-card__link" href="<%= hr_lite.attendance_path %>">View my month</a>
97
+ <% if HrLite.config.office_geofence %>
98
+ <a class="hrl-card__link" href="<%= hr_lite.wfh_requests_path %>">Work from home</a>
99
+ <a class="hrl-card__link" href="<%= hr_lite.work_locations_path %>">WFH locations</a>
100
+ <a class="hrl-card__link" href="<%= hr_lite.work_time_path %>">My work time</a>
101
+ <% end %>
102
+ </div>
83
103
 
84
104
  <% if local_assigns.key?(:attention) %>
85
105
  <div class="hrl-row">