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,62 @@
1
+ module HrLite
2
+ # A place an employee may work from at home. Coordinates come from the
3
+ # device at "Register here", never typed; HR approves before it counts.
4
+ class WorkLocation < ApplicationRecord
5
+ STATUSES = %w[pending approved rejected removed].freeze
6
+ MAX_LIVE = 3
7
+ MAX_ACCURACY_M = 100 # a vaguer fix can't pin a home
8
+ FENCE_M = 500
9
+
10
+ belongs_to :user, class_name: HrLite.config.user_class
11
+ belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
12
+
13
+ validates :name, presence: true, length: { maximum: 60 }
14
+ validates :address, length: { maximum: 200 }
15
+ validates :status, inclusion: { in: STATUSES }
16
+ validates :lat, numericality: { greater_than_or_equal_to: -90, less_than_or_equal_to: 90 }
17
+ validates :lng, numericality: { greater_than_or_equal_to: -180, less_than_or_equal_to: 180 }
18
+ validates :accuracy_m, numericality: { greater_than: 0, less_than_or_equal_to: MAX_ACCURACY_M,
19
+ message: "must be %{count} m or better — step outside or wait for GPS and try again" }
20
+ validate :within_limit, on: :create
21
+
22
+ scope :live, -> { where(status: %w[pending approved]) }
23
+ scope :approved, -> { where(status: "approved") }
24
+ scope :pending, -> { where(status: "pending") }
25
+
26
+ STATUSES.each { |s| define_method("#{s}?") { status == s } }
27
+
28
+ def self.active_for(user_id) = approved.find_by(user_id: user_id, active: true)
29
+
30
+ def covers?(lat, lng) = Geo.distance_m(self.lat, self.lng, lat.to_f, lng.to_f) <= FENCE_M
31
+
32
+ def map_url = "https://www.google.com/maps?q=#{lat},#{lng}"
33
+
34
+ def decide!(status, actor:, note: nil)
35
+ raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending?
36
+
37
+ update!(status: status, decided_by_id: actor.id, decided_at: Time.current, decision_note: note)
38
+ make_active! if approved? && !self.class.approved.where(user_id: user_id, active: true).exists?
39
+ AuditLog.record!(action: "work_location.#{status}", subject: self, actor: actor, changes: { "name" => name, "note" => note }.compact)
40
+ end
41
+
42
+ def make_active!
43
+ transaction do
44
+ self.class.where(user_id: user_id, active: true).where.not(id: id).update_all(active: false)
45
+ update!(active: true)
46
+ end
47
+ end
48
+
49
+ def remove!(actor:)
50
+ update!(status: "removed", active: false)
51
+ AuditLog.record!(action: "work_location.removed", subject: self, actor: actor, changes: { "name" => name })
52
+ end
53
+
54
+ private
55
+
56
+ def within_limit
57
+ return unless self.class.live.where(user_id: user_id).count >= MAX_LIVE
58
+
59
+ errors.add(:base, "You can register at most #{MAX_LIVE} locations. Remove one first.")
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,84 @@
1
+ module HrLite
2
+ # One stretch of a work mode. Open rows (ended_at nil) are the current
3
+ # status — at most one primary and one overlay per person; closed rows are
4
+ # the day's timeline. Never deleted.
5
+ class WorkModeEvent < ApplicationRecord
6
+ # mode => [ label, layer, place shown to teammates ]
7
+ MODES = {
8
+ "office" => [ "In office", "primary", "Office" ],
9
+ "wfh" => [ "Work from home", "primary", "Home" ],
10
+ "travelling" => [ "Travelling", "primary", "Travelling" ],
11
+ "outside_meeting" => [ "Outside office meeting", "primary", "Client meeting" ],
12
+ "offline" => [ "Offline", "primary", nil ],
13
+ "meeting" => [ "Meeting", "overlay", nil ],
14
+ "lunch" => [ "Lunch break", "overlay", nil ],
15
+ "short_break" => [ "Short break", "overlay", nil ],
16
+ "busy" => [ "Busy", "overlay", nil ],
17
+ "out_of_office" => [ "Out of office", "overlay", nil ],
18
+ "custom" => [ "Custom status", "status", nil ]
19
+ }.freeze
20
+ SOURCES = %w[system employee hr].freeze
21
+ END_REASONS = %w[switched expired ended checked_out day_closed].freeze
22
+
23
+ belongs_to :user, class_name: HrLite.config.user_class
24
+ belongs_to :set_by, class_name: HrLite.config.user_class, optional: true
25
+ belongs_to :attendance_record, optional: true
26
+
27
+ validates :mode, inclusion: { in: MODES.keys }
28
+ validates :source, inclusion: { in: SOURCES }
29
+ validates :end_reason, inclusion: { in: END_REASONS }, allow_nil: true
30
+ validates :title, :client, :destination, length: { maximum: 120 }
31
+ validate :custom_status_shape, if: -> { mode == "custom" }
32
+ validate :ends_after_start
33
+
34
+ before_validation { self.layer = MODES.dig(mode, 1) if mode }
35
+
36
+ scope :open, -> { where(ended_at: nil) }
37
+ scope :primary, -> { where(layer: "primary") }
38
+ scope :overlay, -> { where(layer: "overlay") }
39
+ scope :work, -> { where.not(layer: "status") }
40
+
41
+ def self.label(mode) = MODES.dig(mode, 0)
42
+
43
+ # Closes the open row on the same layer and opens this one, atomically.
44
+ def self.start!(user_id:, mode:, source:, actor: nil, at: Time.current, **attrs)
45
+ transaction do
46
+ layer = MODES.dig(mode, 1)
47
+ open.where(user_id: user_id, layer: layer).lock.each { |e| e.close!("switched", at: at) }
48
+ event = create!(user_id: user_id, mode: mode, source: source, started_at: at, set_by_id: actor&.id, **attrs)
49
+ AuditLog.record!(action: "work_mode.#{mode}", subject: event, actor: actor || HrLite::Current.actor,
50
+ changes: { "source" => source, "until" => event.ends_at&.iso8601, "reason" => event.reason }.compact)
51
+ event
52
+ end
53
+ end
54
+
55
+ # Ends every open work row for the person (check-out). A custom status
56
+ # stays: it is the person's own and clears on its own timer.
57
+ def self.close_all!(user_id, reason, at: Time.current)
58
+ open.work.where(user_id: user_id).lock.each { |e| e.close!(reason, at: at) }
59
+ end
60
+
61
+ def label = self.class.label(mode)
62
+ def place = MODES.dig(mode, 2)
63
+ def timed? = ends_at.present?
64
+ def expired?(now = Time.current) = ended_at.nil? && ends_at.present? && ends_at <= now
65
+
66
+ def close!(reason, at: Time.current)
67
+ update!(ended_at: [ at, ends_at ].compact.min, end_reason: reason)
68
+ end
69
+
70
+ private
71
+
72
+ # Text and/or one emoji; the emoji is a single symbol, never words.
73
+ def custom_status_shape
74
+ errors.add(:base, "Write a status or pick an emoji.") if title.blank? && emoji.blank?
75
+ return if emoji.blank?
76
+
77
+ errors.add(:emoji, "must be a single emoji") unless emoji.grapheme_clusters.size == 1 && emoji !~ /[[:alnum:][:space:]]/
78
+ end
79
+
80
+ def ends_after_start
81
+ errors.add(:ends_at, "must be after the start") if ends_at && started_at && ends_at <= started_at
82
+ end
83
+ end
84
+ end
@@ -6,17 +6,24 @@ module HrLite
6
6
  class AttendancePuncher
7
7
  # What the browser can legitimately report (geo_punch.js).
8
8
  GEO_STATUSES = %w[ok denied unavailable timeout insecure].freeze
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
9
14
 
10
15
  Result = Struct.new(:record, :error, keyword_init: true) do
11
16
  def ok? = error.nil?
12
17
  end
13
18
 
14
- def self.call(user:, kind:, lat: nil, lng: nil, accuracy_m: nil, geo_status: "ok")
15
- 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
16
22
  end
17
23
 
18
- def initialize(user, kind, lat, lng, accuracy_m, geo_status)
24
+ def initialize(user, kind, lat, lng, accuracy_m, geo_status, field = {})
19
25
  @user = user
26
+ @field = field.transform_values { |v| v.to_s.strip.presence }.compact
20
27
  @kind = kind
21
28
  @lat = lat.presence
22
29
  @lng = lng.presence
@@ -34,6 +41,10 @@ module HrLite
34
41
  "or raise a regularization ticket.")
35
42
  end
36
43
 
44
+ if (error = geofence_error)
45
+ return Result.new(error: error)
46
+ end
47
+
37
48
  record = resolve_record
38
49
  return Result.new(error: "No open check-in to close.") if record.nil?
39
50
 
@@ -43,6 +54,9 @@ module HrLite
43
54
  return Result.new(record: record, error: already_in_message(record)) if record.check_in_at.present?
44
55
 
45
56
  stamp(record, :check_in)
57
+ record.work_mode = @mode
58
+ record.work_location_id = @location&.id
59
+ record.wfh_request_id = @wfh&.id if @mode == "wfh"
46
60
  when :check_out
47
61
  return Result.new(record: record, error: "Check in first.") if record.check_in_at.blank?
48
62
 
@@ -54,6 +68,7 @@ module HrLite
54
68
  apply_geo_flags(record)
55
69
  record.save!
56
70
  apply_slot_rules(record) if @kind == :check_in && HrLite.config.attendance_slots
71
+ track_work_mode(record) if HrLite.config.office_geofence
57
72
  end
58
73
  notify_if_flagged(record)
59
74
  Result.new(record: record)
@@ -76,6 +91,64 @@ module HrLite
76
91
  retry
77
92
  end
78
93
 
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.
99
+ def geofence_error
100
+ return unless @kind == :check_in && HrLite.config.office_geofence && OfficeLocation.active.exists?
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
114
+
115
+ @wfh = WfhRequest.approved_for(@user.id, Date.current)
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
138
+
139
+ def location_error
140
+ "We could not read your location (#{@geo_status}). Turn location on and try again, " \
141
+ "or request Work From Home (WFH) approval from HR."
142
+ end
143
+
144
+ def precise_within?(metres) = @accuracy_m.to_f.positive? && @accuracy_m.to_f <= metres
145
+
146
+ def in_office?
147
+ OfficeLocation.active.any? do |office|
148
+ precise_within?(office.radius_m) && Geo.distance_m(office.lat, office.lng, @lat.to_f, @lng.to_f) <= office.radius_m
149
+ end
150
+ end
151
+
79
152
  def stamp(record, side)
80
153
  record.public_send("#{side}_at=", Time.current)
81
154
  record.public_send("#{side}_lat=", @lat)
@@ -96,7 +169,7 @@ module HrLite
96
169
  penalize(record, record.date, late, "late") if late.positive?
97
170
  return if arrived <= AttendanceSlot::CARRY_AFTER_MINUTE
98
171
 
99
- 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") }
100
173
  return if arrived <= AttendanceSlot::HALF_DAY_AFTER_MINUTE
101
174
 
102
175
  record.update!(status: "half_day", regularization_note: AttendanceRecord::LATE_HALF_DAY_NOTE)
@@ -112,6 +185,8 @@ module HrLite
112
185
  if @lat.nil? || @lng.nil?
113
186
  reason = @geo_status == "ok" ? "unavailable" : @geo_status
114
187
  record.add_flag!("#{label} without GPS (#{reason})")
188
+ elsif record.work_mode && record.work_mode != "office"
189
+ nil # WFH or field work: off-site is the point
115
190
  elsif OfficeLocation.active.exists? && !OfficeLocation.covering?(@lat, @lng)
116
191
  office = OfficeLocation.nearest(@lat, @lng)
117
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