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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -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 +47 -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/wfh_requests_controller.rb +103 -0
- 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/wfh_requests_controller.rb +45 -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 +8 -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/wfh_request.rb +108 -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 +79 -4
- 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 +9 -0
- 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 +7 -1
- data/app/views/hr_lite/admin/wfh_requests/index.html.erb +91 -0
- data/app/views/hr_lite/admin/wfh_requests/show.html.erb +40 -0
- 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 +23 -0
- 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/wfh_requests/index.html.erb +70 -0
- 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 +22 -0
- data/db/migrate/20261013100000_create_hr_lite_wfh_requests.rb +22 -0
- data/db/migrate/20261013100100_add_wfh_request_id_to_hr_lite_attendance_records.rb +7 -0
- data/db/migrate/20261013100200_add_permanent_wfh_to_hr_lite_employee_profiles.rb +5 -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 +16 -1
- data/lib/hr_lite/notifications.rb +13 -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 +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
|
-
|
|
15
|
-
|
|
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.
|
|
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
|