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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/app/assets/javascripts/hr_lite/chart_tip.js +33 -0
- data/app/assets/javascripts/hr_lite/countdown.js +12 -0
- data/app/assets/javascripts/hr_lite/crm_picker.js +27 -0
- data/app/assets/javascripts/hr_lite/emoji_pick.js +11 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +59 -0
- data/app/controllers/hr_lite/admin/attendances_controller.rb +8 -5
- data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/holidays_controller.rb +17 -9
- data/app/controllers/hr_lite/admin/settings_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/work_locations_controller.rb +41 -0
- data/app/controllers/hr_lite/admin/work_modes_controller.rb +72 -0
- data/app/controllers/hr_lite/admin/work_time_controller.rb +39 -0
- data/app/controllers/hr_lite/attendance_controller.rb +2 -1
- data/app/controllers/hr_lite/calendar_controller.rb +3 -2
- data/app/controllers/hr_lite/comp_off_requests_controller.rb +1 -1
- data/app/controllers/hr_lite/holidays_controller.rb +3 -2
- data/app/controllers/hr_lite/team_controller.rb +13 -0
- data/app/controllers/hr_lite/work_locations_controller.rb +44 -0
- data/app/controllers/hr_lite/work_modes_controller.rb +40 -0
- data/app/controllers/hr_lite/work_time_controller.rb +14 -0
- data/app/helpers/hr_lite/application_helper.rb +43 -2
- data/app/jobs/hr_lite/attendance_close_job.rb +12 -5
- data/app/jobs/hr_lite/calendar_meeting_start_job.rb +31 -0
- data/app/jobs/hr_lite/calendar_meeting_sync_job.rb +55 -0
- data/app/jobs/hr_lite/work_mode_expire_job.rb +12 -0
- data/app/models/hr_lite/attendance_penalty.rb +2 -2
- data/app/models/hr_lite/attendance_record.rb +7 -0
- data/app/models/hr_lite/comp_off_request.rb +2 -2
- data/app/models/hr_lite/employee_profile.rb +2 -0
- data/app/models/hr_lite/holiday.rb +11 -5
- data/app/models/hr_lite/leave_balance.rb +1 -1
- data/app/models/hr_lite/leave_request.rb +1 -1
- data/app/models/hr_lite/regularization_request.rb +1 -1
- data/app/models/hr_lite/setting.rb +2 -0
- data/app/models/hr_lite/work_location.rb +62 -0
- data/app/models/hr_lite/work_mode_event.rb +84 -0
- data/app/services/hr_lite/attendance_puncher.rb +60 -19
- data/app/services/hr_lite/day_status.rb +1 -1
- data/app/services/hr_lite/leave_day_counter.rb +5 -7
- data/app/services/hr_lite/team_day.rb +6 -6
- data/app/services/hr_lite/today_checkins.rb +41 -0
- data/app/services/hr_lite/work_mode_durations.rb +237 -0
- data/app/services/hr_lite/work_mode_switch.rb +144 -0
- data/app/services/hr_lite/work_status.rb +60 -0
- data/app/services/hr_lite/working_calendar.rb +11 -2
- data/app/views/hr_lite/admin/attendances/show.html.erb +1 -1
- data/app/views/hr_lite/admin/employees/_form.html.erb +7 -1
- data/app/views/hr_lite/admin/holidays/index.html.erb +10 -1
- data/app/views/hr_lite/admin/settings/edit.html.erb +9 -0
- data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +4 -1
- data/app/views/hr_lite/admin/wfh_requests/index.html.erb +2 -2
- data/app/views/hr_lite/admin/work_locations/index.html.erb +42 -0
- data/app/views/hr_lite/admin/work_modes/index.html.erb +105 -0
- data/app/views/hr_lite/admin/work_time/index.html.erb +42 -0
- data/app/views/hr_lite/attendance/_calendar_connection.html.erb +15 -0
- data/app/views/hr_lite/attendance/_crm_picker.html.erb +9 -0
- data/app/views/hr_lite/attendance/_custom_status.html.erb +26 -0
- data/app/views/hr_lite/attendance/_punch_card.html.erb +26 -6
- data/app/views/hr_lite/attendance/_work_mode.html.erb +92 -0
- data/app/views/hr_lite/attendance/show.html.erb +4 -1
- data/app/views/hr_lite/holidays/_next_holiday.html.erb +1 -1
- data/app/views/hr_lite/home/_today_checkins.html.erb +31 -0
- data/app/views/hr_lite/home/index.html.erb +5 -0
- data/app/views/hr_lite/team/show.html.erb +25 -4
- data/app/views/hr_lite/work_locations/index.html.erb +52 -0
- data/app/views/hr_lite/work_time/_bars.html.erb +44 -0
- data/app/views/hr_lite/work_time/_donut.html.erb +36 -0
- data/app/views/hr_lite/work_time/_report.html.erb +74 -0
- data/app/views/hr_lite/work_time/_timeline.html.erb +26 -0
- data/app/views/hr_lite/work_time/show.html.erb +10 -0
- data/config/routes.rb +16 -0
- data/db/migrate/20261013110000_create_hr_lite_work_locations.rb +23 -0
- data/db/migrate/20261013110100_add_work_mode_to_hr_lite_attendance_records.rb +7 -0
- data/db/migrate/20261013110200_add_field_staff_to_hr_lite_employee_profiles.rb +5 -0
- data/db/migrate/20261013110300_create_hr_lite_work_mode_events.rb +30 -0
- data/db/migrate/20261013110400_add_break_minutes_to_hr_lite_settings.rb +6 -0
- data/db/migrate/20261013120000_grant_hr_lite_work_analytics.rb +14 -0
- data/db/migrate/20261013130000_add_office_location_to_hr_lite_holidays.rb +12 -0
- data/db/migrate/20261013130100_add_office_location_to_hr_lite_employee_profiles.rb +6 -0
- data/db/migrate/20261013140000_add_calendar_and_crm_to_hr_lite_work_mode_events.rb +9 -0
- data/db/migrate/20261013140100_add_custom_status_to_hr_lite_work_mode_events.rb +9 -0
- data/lib/hr_lite/configuration.rb +13 -1
- data/lib/hr_lite/notifications.rb +9 -4
- data/lib/hr_lite/permissions.rb +1 -0
- data/lib/hr_lite/role_seeds.rb +4 -2
- data/lib/hr_lite/seeds.rb +1 -1
- data/lib/hr_lite/version.rb +1 -1
- 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
|
-
|
|
16
|
-
|
|
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.
|
|
52
|
-
record.
|
|
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
|
|
87
|
-
#
|
|
88
|
-
#
|
|
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
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
-
@
|
|
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.
|
|
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.
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
@
|
|
16
|
+
@calendars = WorkingCalendar.by_office(date..date)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
attr_reader :date
|
|
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
|
|
63
|
-
return :weekend if
|
|
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
|