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
@@ -7,17 +7,23 @@ module HrLite
7
7
  # What the browser can legitimately report (geo_punch.js).
8
8
  GEO_STATUSES = %w[ok denied unavailable timeout insecure].freeze
9
9
  OFF_SITE_MESSAGE = "You are not within the office location. Please request Work From Home (WFH) approval from HR.".freeze
10
+ HOME_OFF_SITE_MESSAGE = "You are outside your registered WFH location. Please select an approved location " \
11
+ "or switch to Travelling Mode if you are travelling.".freeze
12
+ NO_HOME_MESSAGE = "You have WFH today but no approved WFH location. Register one under WFH locations and ask HR to approve it.".freeze
13
+ FIELD_MODES = %w[travelling outside_meeting].freeze
10
14
 
11
15
  Result = Struct.new(:record, :error, keyword_init: true) do
12
16
  def ok? = error.nil?
13
17
  end
14
18
 
15
- def self.call(user:, kind:, lat: nil, lng: nil, accuracy_m: nil, geo_status: "ok")
16
- new(user, kind.to_sym, lat, lng, accuracy_m, geo_status.to_s).call
19
+ # field: { mode:, purpose:, place: } — a field-staff check-in away from the office.
20
+ def self.call(user:, kind:, lat: nil, lng: nil, accuracy_m: nil, geo_status: "ok", field: {})
21
+ new(user, kind.to_sym, lat, lng, accuracy_m, geo_status.to_s, field.to_h.symbolize_keys).call
17
22
  end
18
23
 
19
- def initialize(user, kind, lat, lng, accuracy_m, geo_status)
24
+ def initialize(user, kind, lat, lng, accuracy_m, geo_status, field = {})
20
25
  @user = user
26
+ @field = field.transform_values { |v| v.to_s.strip.presence }.compact
21
27
  @kind = kind
22
28
  @lat = lat.presence
23
29
  @lng = lng.presence
@@ -48,8 +54,9 @@ module HrLite
48
54
  return Result.new(record: record, error: already_in_message(record)) if record.check_in_at.present?
49
55
 
50
56
  stamp(record, :check_in)
51
- record.wfh = !!@remote
52
- record.wfh_request_id = @wfh&.id if @remote
57
+ record.work_mode = @mode
58
+ record.work_location_id = @location&.id
59
+ record.wfh_request_id = @wfh&.id if @mode == "wfh"
53
60
  when :check_out
54
61
  return Result.new(record: record, error: "Check in first.") if record.check_in_at.blank?
55
62
 
@@ -61,6 +68,7 @@ module HrLite
61
68
  apply_geo_flags(record)
62
69
  record.save!
63
70
  apply_slot_rules(record) if @kind == :check_in && HrLite.config.attendance_slots
71
+ track_work_mode(record) if HrLite.config.office_geofence
64
72
  end
65
73
  notify_if_flagged(record)
66
74
  Result.new(record: record)
@@ -83,28 +91,61 @@ module HrLite
83
91
  retry
84
92
  end
85
93
 
86
- # config.office_geofence: a check-in must be inside an office radius with
87
- # a fix at least that precise, unless HR approved WFH for today or made
88
- # the person permanent WFH. Inside the radius the day is office either way.
94
+ # config.office_geofence decides where a check-in may come from, and so
95
+ # the day's work_mode: inside an office radius is office (even on a WFH
96
+ # day); a field-staff check-in that says where and why is travelling or
97
+ # outside_meeting; approved WFH (dated or permanent) must be within the
98
+ # active approved home location's fence. Anything else is refused.
89
99
  def geofence_error
90
100
  return unless @kind == :check_in && HrLite.config.office_geofence && OfficeLocation.active.exists?
91
- return if in_office?
101
+ return location_error unless @lat && @lng
102
+ if in_office?
103
+ @mode = "office"
104
+ return
105
+ end
106
+
107
+ profile = EmployeeProfile.find_by(user_id: @user.id)
108
+ if profile&.field_staff && FIELD_MODES.include?(@field[:mode])
109
+ return "Say why and where you are checking in from the field." unless @field[:purpose] && @field[:place]
110
+
111
+ @mode = @field[:mode]
112
+ return
113
+ end
92
114
 
93
115
  @wfh = WfhRequest.approved_for(@user.id, Date.current)
94
- @remote = @wfh.present? || EmployeeProfile.where(user_id: @user.id).pick(:permanent_wfh)
95
- return if @remote
96
- return OFF_SITE_MESSAGE if @lat && @lng
116
+ return OFF_SITE_MESSAGE unless @wfh || profile&.permanent_wfh
117
+
118
+ @location = WorkLocation.active_for(@user.id)
119
+ return NO_HOME_MESSAGE unless @location
120
+ return HOME_OFF_SITE_MESSAGE unless precise_within?(WorkLocation::FENCE_M) && @location.covers?(@lat, @lng)
121
+
122
+ @mode = "wfh"
123
+ nil
124
+ end
125
+
126
+ # Check-in opens the day's system-set mode; check-out ends everything and
127
+ # opens Offline. The timeline keeps every earlier row.
128
+ def track_work_mode(record)
129
+ if @kind == :check_in
130
+ WorkModeEvent.start!(user_id: @user.id, mode: record.work_mode || "office", source: "system",
131
+ attendance_record: record, wfh_request_id: record.wfh_request_id,
132
+ work_location_id: record.work_location_id, title: @field[:purpose], destination: @field[:place])
133
+ else
134
+ WorkModeEvent.close_all!(@user.id, "checked_out")
135
+ WorkModeEvent.start!(user_id: @user.id, mode: "offline", source: "system", attendance_record: record)
136
+ end
137
+ end
97
138
 
139
+ def location_error
98
140
  "We could not read your location (#{@geo_status}). Turn location on and try again, " \
99
141
  "or request Work From Home (WFH) approval from HR."
100
142
  end
101
143
 
102
- def in_office?
103
- return false unless @lat && @lng && @accuracy_m.to_f.positive?
144
+ def precise_within?(metres) = @accuracy_m.to_f.positive? && @accuracy_m.to_f <= metres
104
145
 
146
+ def in_office?
105
147
  OfficeLocation.active.any? do |office|
106
- @accuracy_m.to_f <= office.radius_m &&
107
- Geo.distance_m(office.lat, office.lng, @lat.to_f, @lng.to_f) <= office.radius_m
148
+ precise_within?(office.radius_m) && Geo.distance_m(office.lat, office.lng, @lat.to_f, @lng.to_f) <= office.radius_m
108
149
  end
109
150
  end
110
151
 
@@ -128,7 +169,7 @@ module HrLite
128
169
  penalize(record, record.date, late, "late") if late.positive?
129
170
  return if arrived <= AttendanceSlot::CARRY_AFTER_MINUTE
130
171
 
131
- AttendancePenalty.next_working_days(record.date, 2).each { |day| penalize(record, day, 60, "late_carry") }
172
+ AttendancePenalty.next_working_days(record.user_id, record.date, 2).each { |day| penalize(record, day, 60, "late_carry") }
132
173
  return if arrived <= AttendanceSlot::HALF_DAY_AFTER_MINUTE
133
174
 
134
175
  record.update!(status: "half_day", regularization_note: AttendanceRecord::LATE_HALF_DAY_NOTE)
@@ -144,8 +185,8 @@ module HrLite
144
185
  if @lat.nil? || @lng.nil?
145
186
  reason = @geo_status == "ok" ? "unavailable" : @geo_status
146
187
  record.add_flag!("#{label} without GPS (#{reason})")
147
- elsif record.wfh
148
- nil # WFH: off-site is the point
188
+ elsif record.work_mode && record.work_mode != "office"
189
+ nil # WFH or field work: off-site is the point
149
190
  elsif OfficeLocation.active.exists? && !OfficeLocation.covering?(@lat, @lng)
150
191
  office = OfficeLocation.nearest(@lat, @lng)
151
192
  distance_km = (Geo.distance_m(office.lat, office.lng, @lat, @lng) / 1000.0).round(1)
@@ -13,7 +13,7 @@ module HrLite
13
13
  def initialize(user:, range:)
14
14
  @range = range
15
15
  @records = AttendanceRecord.where(user_id: user.id, date: range).index_by(&:date)
16
- @calendar = WorkingCalendar.new(range)
16
+ @calendar = WorkingCalendar.for_user(user.id, range)
17
17
  @leaves = LeaveRequest.approved.includes(:leave_type)
18
18
  .where(user_id: user.id)
19
19
  .overlapping_range(range.first, range.last).to_a
@@ -1,19 +1,17 @@
1
1
  module HrLite
2
2
  # How many working days a leave request actually consumes. Weekends and
3
3
  # company-wide holidays inside the range are free; a half-day (single-day
4
- # requests only) consumes 0.5.
4
+ # requests only) consumes 0.5. Holidays are the requester's office's.
5
5
  module LeaveDayCounter
6
6
  # `calendar:` lets a caller counting many requests over the same span
7
7
  # build ONE calendar and reuse it. Each one costs a Holiday query and a
8
8
  # Setting query, and the balances grid counts every approved request of
9
9
  # every employee for every leave type.
10
+ # Without a calendar, the requester's own (their office's holidays count).
10
11
  def self.count(leave_request, calendar: nil)
11
- count_range(
12
- start_date: leave_request.start_date,
13
- end_date: leave_request.end_date,
14
- half_day: leave_request.half_day,
15
- calendar: calendar
16
- )
12
+ start_date, end_date = leave_request.start_date, leave_request.end_date
13
+ calendar ||= WorkingCalendar.for_user(leave_request.user_id, start_date..end_date) if start_date && end_date && end_date >= start_date
14
+ count_range(start_date: start_date, end_date: end_date, half_day: leave_request.half_day, calendar: calendar)
17
15
  end
18
16
 
19
17
  def self.count_range(start_date:, end_date:, half_day: false, calendar: nil)
@@ -13,10 +13,10 @@ module HrLite
13
13
 
14
14
  def initialize(date: Date.current)
15
15
  @date = date
16
- @calendar = WorkingCalendar.new(date..date)
16
+ @calendars = WorkingCalendar.by_office(date..date)
17
17
  end
18
18
 
19
- attr_reader :date, :calendar
19
+ attr_reader :date
20
20
 
21
21
  def rows
22
22
  @rows ||= build_rows
@@ -49,7 +49,7 @@ module HrLite
49
49
  leave = leaves[user.id]
50
50
  Row.new(
51
51
  user: user, profile: profile,
52
- kind: kind_for(record, leave),
52
+ kind: kind_for(record, leave, @calendars[profile&.office_location_id]),
53
53
  record: record, leave: leave,
54
54
  day_seconds: seconds_worked(record),
55
55
  month_seconds: (month_records[user.id] || []).sum { |r| seconds_worked(r) || 0 },
@@ -58,9 +58,9 @@ module HrLite
58
58
  end
59
59
  end
60
60
 
61
- def kind_for(record, leave)
62
- return :holiday if @calendar.holiday?(@date)
63
- return :weekend if @calendar.weekend?(@date)
61
+ def kind_for(record, leave, calendar)
62
+ return :holiday if calendar.holiday?(@date)
63
+ return :weekend if calendar.weekend?(@date)
64
64
  return (leave.half_day ? :half_day_leave : :leave) if leave
65
65
 
66
66
  if record&.check_in_at
@@ -0,0 +1,41 @@
1
+ module HrLite
2
+ # Who is in today and whether on time, for the Home card. "On time" uses
3
+ # each person's own slot plus their HR buffer and the grace minutes — the
4
+ # same rule as the late penalty. Leave, holidays and weekends are left out.
5
+ class TodayCheckins
6
+ Person = Struct.new(:user, :record, :late_minutes, keyword_init: true) do
7
+ def late? = late_minutes.to_i.positive?
8
+ end
9
+
10
+ def initialize(board = TeamDay.new(date: Date.current), now: Time.current)
11
+ @board = board
12
+ @now = now
13
+ end
14
+
15
+ def groups
16
+ @groups ||= begin
17
+ rows = @board.rows.reject { |r| %i[holiday weekend leave].include?(r.kind) }
18
+ ids = rows.map { |r| r.user.id }
19
+ starts = AttendanceSlot.where(user_id: ids).overlapping(@board.date, @board.date).order(:created_at)
20
+ .each_with_object({}) { |s, h| h[s.user_id] = s.start_minute }
21
+ buffers = EmployeeProfile.where(user_id: ids).pluck(:user_id, :buffer_minutes).to_h
22
+ people = rows.map do |r|
23
+ Person.new(user: r.user, record: r.record&.check_in_at ? r.record : nil,
24
+ late_minutes: late(r.record, starts[r.user.id], buffers[r.user.id]))
25
+ end
26
+ in_now, not_in = people.partition(&:record)
27
+ { on_time: in_now.reject(&:late?), late: in_now.select(&:late?), not_in: not_in }
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def late(record, start, buffer)
34
+ return 0 unless record&.check_in_at
35
+
36
+ local = record.check_in_at.in_time_zone(HrLite.config.time_zone)
37
+ over = local.hour * 60 + local.min - (start || AttendanceSlot::DEFAULT_START_MINUTE) - buffer.to_i
38
+ over > AttendanceSlot::GRACE_MINUTES ? over : 0
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,237 @@
1
+ module HrLite
2
+ # Time spent per work mode, worked out from the mode timeline every time it
3
+ # is asked (no cache, so a correction shows at once). A break sitting on top
4
+ # of a work mode counts as the break only: each moment belongs to the
5
+ # overlay if one is running, else to the primary mode. Open rows run to now,
6
+ # or to their end time if that has already passed.
7
+ class WorkModeDurations
8
+ WORKING = %w[office wfh travelling outside_meeting meeting busy].freeze
9
+ BREAKS = %w[lunch short_break].freeze
10
+ OTHER = %w[out_of_office].freeze
11
+
12
+ # A reporting window in the company time zone, with the bucket its charts use.
13
+ class Period
14
+ KINDS = %w[day week month quarter year custom].freeze
15
+ MAX_DAYS = 366
16
+
17
+ attr_reader :kind, :from, :to
18
+
19
+ def self.build(kind, date, from: nil, to: nil)
20
+ kind = KINDS.include?(kind.to_s) ? kind.to_s : "week"
21
+ case kind
22
+ when "day" then new(kind, date, date)
23
+ when "week" then new(kind, date.beginning_of_week(:monday), date.end_of_week(:monday))
24
+ when "month" then new(kind, date.beginning_of_month, date.end_of_month)
25
+ when "quarter" then new(kind, date.beginning_of_quarter, date.end_of_quarter)
26
+ when "year" then new(kind, date.beginning_of_year, date.end_of_year)
27
+ else
28
+ raise ArgumentError, "Pick a start and an end date." unless from && to
29
+ raise ArgumentError, "The start date is after the end date." if from > to
30
+ raise ArgumentError, "Pick at most #{MAX_DAYS} days." if (to - from).to_i + 1 > MAX_DAYS
31
+
32
+ new(kind, from, to)
33
+ end
34
+ end
35
+
36
+ # From request params: period=, date=, from=, to=. Returns [period, error].
37
+ def self.from_params(params, today)
38
+ date = [ parse_date(params[:date]) || today, today ].min
39
+ [ build(params[:period], date, from: parse_date(params[:from]), to: parse_date(params[:to])), nil ]
40
+ rescue ArgumentError => e
41
+ [ build("week", today), e.message ]
42
+ end
43
+
44
+ def self.parse_date(value)
45
+ Date.iso8601(value.to_s)
46
+ rescue Date::Error
47
+ nil
48
+ end
49
+
50
+ def initialize(kind, from, to)
51
+ @kind, @from, @to = kind, from, to
52
+ end
53
+
54
+ def bucket
55
+ case kind
56
+ when "day" then :hour
57
+ when "quarter" then :week
58
+ when "year" then :month
59
+ when "custom" then (to - from).to_i + 1 > 62 ? :week : :day
60
+ else :day
61
+ end
62
+ end
63
+
64
+ def label
65
+ case kind
66
+ when "day" then from.strftime("%A %-d %B %Y")
67
+ when "month" then from.strftime("%B %Y")
68
+ when "quarter" then "Q#{(from.month - 1) / 3 + 1} #{from.year}"
69
+ when "year" then from.year.to_s
70
+ else "#{from.strftime('%-d %b %Y')} – #{to.strftime('%-d %b %Y')}"
71
+ end
72
+ end
73
+
74
+ def shift(direction)
75
+ step = { "day" => 1.day, "week" => 1.week, "month" => 1.month, "quarter" => 3.months, "year" => 1.year }[kind]
76
+ step ? from + (direction * step) : from
77
+ end
78
+ end
79
+
80
+ Segment = Struct.new(:user_id, :mode, :from, :to, :source, :event_id) do
81
+ def seconds = to - from
82
+ end
83
+
84
+ Result = Struct.new(:segments, :by_mode, :by_bucket, :by_source, :attendance, :working, :breaks, :other,
85
+ :offline, :days_with_data, :gaps, keyword_init: true) do
86
+ def tracked = working + breaks + other
87
+ end
88
+
89
+ # A period that is over is cached under a key built from the newest change
90
+ # to its events and check-ins, so a correction is picked up on the next
91
+ # read. A period that includes today is always worked out live.
92
+ def self.call(user_ids, period, now: Time.current)
93
+ ids = Array(user_ids)
94
+ return new(ids, period, now).call if period.to >= now.in_time_zone(HrLite.config.time_zone).to_date
95
+
96
+ Rails.cache.fetch(cache_key(ids, period)) { new(ids, period, now).call }
97
+ end
98
+
99
+ def self.cache_key(ids, period)
100
+ zone = Time.find_zone!(HrLite.config.time_zone)
101
+ start = zone.local(period.from.year, period.from.month, period.from.day)
102
+ finish = zone.local(period.to.year, period.to.month, period.to.day).end_of_day
103
+ events = WorkModeEvent.where(user_id: ids).where("started_at <= ? AND (ended_at IS NULL OR ended_at >= ?)", finish, start)
104
+ records = AttendanceRecord.where(user_id: ids, date: period.from..period.to)
105
+ [ "hr_lite/work_time/v1", Digest::SHA256.hexdigest(ids.sort.join(",")), period.kind, period.from, period.to,
106
+ events.count, events.maximum(:updated_at)&.to_f, records.count, records.maximum(:updated_at)&.to_f ].join("/")
107
+ end
108
+
109
+ def initialize(user_ids, period, now)
110
+ @ids = user_ids
111
+ @period = period
112
+ @now = now
113
+ @zone = Time.find_zone!(HrLite.config.time_zone)
114
+ @start = @zone.local(period.from.year, period.from.month, period.from.day)
115
+ @finish = [ @zone.local(period.to.year, period.to.month, period.to.day).end_of_day, now ].min
116
+ end
117
+
118
+ def call
119
+ events = @finish > @start ? overlapping_events.group_by(&:user_id) : {}
120
+ segments = @ids.flat_map { |id| flatten(id, events.fetch(id, [])) }
121
+ records = AttendanceRecord.where(user_id: @ids, date: @period.from..@period.to).where.not(check_in_at: nil).to_a
122
+
123
+ by_mode = Hash.new(0)
124
+ by_source = Hash.new(0)
125
+ by_bucket = bucket_keys.index_with { Hash.new(0) }
126
+ segments.each do |seg|
127
+ by_mode[seg.mode] += seg.seconds
128
+ by_source[seg.source] += seg.seconds unless seg.mode == "offline"
129
+ split(seg).each { |key, secs| by_bucket[key][seg.mode] += secs if by_bucket.key?(key) }
130
+ end
131
+
132
+ Result.new(
133
+ segments: segments, by_mode: by_mode, by_bucket: by_bucket, by_source: by_source,
134
+ attendance: records.sum { |r| attended(r) },
135
+ working: WORKING.sum { |m| by_mode[m] }, breaks: BREAKS.sum { |m| by_mode[m] },
136
+ other: OTHER.sum { |m| by_mode[m] }, offline: by_mode["offline"],
137
+ days_with_data: (records.map(&:date) + segments.reject { |s| s.mode == "offline" }.map { |s| s.from.in_time_zone(@zone).to_date }).to_set,
138
+ gaps: gaps(records, segments)
139
+ )
140
+ end
141
+
142
+ private
143
+
144
+ def overlapping_events
145
+ WorkModeEvent.where(user_id: @ids).where("started_at < ? AND (ended_at IS NULL OR ended_at > ?)", @finish, @start)
146
+ .order(:started_at, :id).to_a
147
+ end
148
+
149
+ # Each layer is a sorted, non-overlapping list (one open row per layer is
150
+ # enforced by the database), so one sweep over the boundaries assigns
151
+ # every slice to its overlay or its primary without counting it twice.
152
+ def flatten(user_id, events)
153
+ layers = events.group_by(&:layer).transform_values { |list| list.map { |e| [ clip_start(e), clip_end(e), e ] }.select { |s, f, _| f > s } }
154
+ primaries = layers.fetch("primary", [])
155
+ overlays = layers.fetch("overlay", [])
156
+ points = (primaries + overlays).flat_map { |s, f, _| [ s, f ] }.uniq.sort
157
+ pi = oi = 0
158
+ out = []
159
+ points.each_cons(2) do |a, b|
160
+ pi += 1 while pi < primaries.size && primaries[pi][1] <= a
161
+ oi += 1 while oi < overlays.size && overlays[oi][1] <= a
162
+ event = [ overlays[oi], primaries[pi] ].compact.find { |s, f, _| s <= a && f >= b }&.last
163
+ next unless event
164
+
165
+ if out.last&.event_id == event.id && out.last.to == a
166
+ out.last.to = b
167
+ else
168
+ out << Segment.new(user_id, event.mode, a, b, event.source, event.id)
169
+ end
170
+ end
171
+ out
172
+ end
173
+
174
+ def clip_start(event) = [ event.started_at, @start ].max
175
+
176
+ def clip_end(event)
177
+ ended = event.ended_at || (event.ends_at && event.ends_at <= @now ? event.ends_at : @now)
178
+ [ ended, @finish ].min
179
+ end
180
+
181
+ # Check-in to check-out; today's open punch runs to now, an older open one counts nothing.
182
+ def attended(record)
183
+ return record.check_out_at - record.check_in_at if record.check_out_at
184
+ return [ @now - record.check_in_at, 0 ].max if record.date == @now.in_time_zone(@zone).to_date
185
+
186
+ 0
187
+ end
188
+
189
+ def bucket_keys
190
+ days = (@period.from..[ @period.to, @now.in_time_zone(@zone).to_date ].min).to_a
191
+ case @period.bucket
192
+ when :hour then (0..23).to_a
193
+ when :day then days
194
+ when :week then days.map { |d| d.beginning_of_week(:monday) }.uniq
195
+ when :month then days.map(&:beginning_of_month).uniq
196
+ end
197
+ end
198
+
199
+ def key_for(time)
200
+ local = time.in_time_zone(@zone)
201
+ case @period.bucket
202
+ when :hour then local.hour
203
+ when :day then local.to_date
204
+ when :week then local.to_date.beginning_of_week(:monday)
205
+ when :month then local.to_date.beginning_of_month
206
+ end
207
+ end
208
+
209
+ # Seconds of a segment per bucket, cut at hour or day boundaries.
210
+ def split(seg)
211
+ step = @period.bucket == :hour ? 1.hour : 1.day
212
+ out = Hash.new(0)
213
+ cursor = seg.from
214
+ while cursor < seg.to
215
+ edge = step == 1.hour ? cursor.in_time_zone(@zone).beginning_of_hour + 1.hour : cursor.in_time_zone(@zone).beginning_of_day + 1.day
216
+ stop = [ edge, seg.to ].min
217
+ out[key_for(cursor)] += stop - cursor
218
+ cursor = stop
219
+ end
220
+ out
221
+ end
222
+
223
+ # What a reviewer should look at: no modes on a worked day, modes with no
224
+ # check-in, a check-out that never happened.
225
+ def gaps(records, segments)
226
+ today = @now.in_time_zone(@zone).to_date
227
+ worked = segments.reject { |s| s.mode == "offline" }.group_by { |s| [ s.user_id, s.from.in_time_zone(@zone).to_date ] }
228
+ punched = records.index_by { |r| [ r.user_id, r.date ] }
229
+ out = records.filter_map do |r|
230
+ if r.check_out_at.nil? && r.date < today then [ r.user_id, r.date, "Check-out missing" ]
231
+ elsif !worked.key?([ r.user_id, r.date ]) then [ r.user_id, r.date, "Checked in, but no work mode recorded" ]
232
+ end
233
+ end
234
+ out + worked.keys.reject { |key| punched.key?(key) }.map { |id, date| [ id, date, "Work modes recorded without a check-in" ] }
235
+ end
236
+ end
237
+ end
@@ -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