hr_lite 0.65.1 → 0.68.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 +24 -0
- data/app/controllers/hr_lite/admin/attendances_controller.rb +3 -2
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +17 -1
- data/app/controllers/hr_lite/admin/wfh_requests_controller.rb +1 -0
- data/app/models/hr_lite/attendance_penalty.rb +6 -0
- data/app/models/hr_lite/leave_request.rb +31 -1
- data/app/services/hr_lite/attendance_puncher.rb +1 -1
- data/app/services/hr_lite/work_mode_switch.rb +4 -1
- data/app/views/hr_lite/admin/attendances/show.html.erb +16 -1
- data/config/routes.rb +1 -1
- data/lib/hr_lite/configuration.rb +3 -0
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +9 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ab802b6ea743100ee2edae138c1af298d2bf94e479c7b89318f573830eb0bd7f
|
|
4
|
+
data.tar.gz: 65f656038862fe47a5504839487118a2a13184ae129ed3ee5ddff8b2342ad066
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f3290aac37da8961636beabff7d3a405b676a0b2a7b3f79d55175f4900b800cc33bbbccaf7bf0aaf398e8e14d56cc3ca23ec25607bc9d5723330c1f0ff4efca
|
|
7
|
+
data.tar.gz: dde58fd2cbb6743baf51adfd5864d06d05de2dbea92e7fe156e6db4780b372722433241354aabc64c3481f4b7d1bae0d890f7f02f8f43c094387467bfbd2c66b
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.68.1] - 2026-11-08
|
|
11
|
+
|
|
12
|
+
A late check-in no longer takes a second. No migrations.
|
|
13
|
+
|
|
14
|
+
- **Leadership is not told each time the system adds an attendance penalty.** Every late check-in created 1 to 3 penalty rows, and each one emailed leadership and rang a bell for every leadership user (about 18 live notifications per penalty). That made a late check-in take up to 1.2 s. The audit log row is still written, and an HR removal still notifies leadership.
|
|
15
|
+
|
|
16
|
+
## [0.68.0] - 2026-11-07
|
|
17
|
+
|
|
18
|
+
HR takes a day out of leave when the person came in. No migrations.
|
|
19
|
+
|
|
20
|
+
- **"Take <day> out of leave" on the attendance Fix panel.** When someone checks in on a day of approved full-day leave, HR (or an admin) opens that day's Fix panel, gives a reason and takes the day out. A one-day leave is cancelled; a longer one shrinks, or splits in two around a middle day, with its paid days going to the earlier part first. The day goes back to the balance, a `leave.day_cancelled` audit row is written, a computed payroll run is flagged for recompute, and the employee is told. Leave an admin approved can only be changed by an admin (0.67.0).
|
|
21
|
+
|
|
22
|
+
## [0.67.0] - 2026-11-07
|
|
23
|
+
|
|
24
|
+
An admin's decision stands over HR. No migrations.
|
|
25
|
+
|
|
26
|
+
- **HR cannot change what an admin decided; an admin can change anything.** "Admin" is anyone holding `role.manage` (Super Admin by default). HR overrides the system as before, but is refused with "An admin decided this. Only an admin can change it." when the thing was last set by an admin: a work mode an admin set (same layer), leave an admin approved (cancel and fix), a WFH request an admin decided, and a punch an admin fixed. The admin is never refused, including over HR's own change. Who decided is read from the existing `set_by_id` / `decided_by_id` / `regularized_by_id`, so nothing is stored twice.
|
|
27
|
+
|
|
28
|
+
## [0.66.0] - 2026-11-07
|
|
29
|
+
|
|
30
|
+
Saturday can be a work-from-home day for everyone. No migrations.
|
|
31
|
+
|
|
32
|
+
- **`config.wfh_weekdays`** (e.g. `[ 6 ]` for Saturday, default `[]`) makes those weekdays count as approved WFH for every employee, like permanent WFH: an off-site check-in is accepted at their home location instead of being refused, and the WFH work mode can be picked. A check-in inside an office radius is still In office that day. Other days are unchanged: off-site needs an approved WFH request.
|
|
33
|
+
|
|
10
34
|
## [0.65.1] - 2026-11-03
|
|
11
35
|
|
|
12
36
|
- **Salary structures card has its own "Add salary structure" button.** It used to hide inside the employee's "More actions" menu, so the empty card ("payroll will skip this employee") gave no way to add one. More actions now holds only appraisal, designation change and PIP.
|
|
@@ -58,6 +58,7 @@ module HrLite
|
|
|
58
58
|
@counts[:absent] -= 1 if @month.all_month.cover?(Date.current) && @day_status.for(Date.current).kind == :absent
|
|
59
59
|
@edit_date = params[:date].present? ? parse_date_param(params[:date]) : nil
|
|
60
60
|
@edit_record = @edit_date && AttendanceRecord.find_or_initialize_by(user_id: @employee.id, date: @edit_date)
|
|
61
|
+
@edit_leave = @edit_record&.check_in_at && LeaveRequest.active_on(@edit_date).find_by(user_id: @employee.id, half_day: false)
|
|
61
62
|
@punches = AttendanceRecord.for_month(@month).where(user_id: @employee.id).where.not(check_in_at: nil)
|
|
62
63
|
.includes(:work_location, :check_out_work_location).order(:date)
|
|
63
64
|
@offices = OfficeLocation.active.to_a
|
|
@@ -73,9 +74,9 @@ module HrLite
|
|
|
73
74
|
record = AttendanceRecord.find_or_initialize_by(user_id: @employee.id, date: date)
|
|
74
75
|
|
|
75
76
|
note = params.dig(:attendance_record, :regularization_note).to_s.strip
|
|
76
|
-
if note.blank?
|
|
77
|
+
if note.blank? || HrLite.outranked?(hr_current_user, record.regularized_by_id)
|
|
77
78
|
return redirect_to admin_attendance_path(@employee.id, date: date, month: date.strftime("%Y-%m")),
|
|
78
|
-
alert: "A regularization note is required."
|
|
79
|
+
alert: note.blank? ? "A regularization note is required." : HrLite::ADMIN_DECIDED
|
|
79
80
|
end
|
|
80
81
|
|
|
81
82
|
attrs = params.require(:attendance_record).permit(:check_in_at, :check_out_at, :status, :half_day_part)
|
|
@@ -49,8 +49,9 @@ module HrLite
|
|
|
49
49
|
@request = find_recorded
|
|
50
50
|
reason = params[:correction_reason].to_s.strip
|
|
51
51
|
@request.errors.add(:base, "A reason is required to fix this leave") if reason.blank?
|
|
52
|
+
@request.errors.add(:base, HrLite::ADMIN_DECIDED) if HrLite.outranked?(hr_current_user, @request.decided_by_id)
|
|
52
53
|
attrs = params.require(:leave_request).permit(:leave_type_id, :start_date, :end_date, :half_day_part)
|
|
53
|
-
if
|
|
54
|
+
if @request.errors.empty? && @request.correct!(actor: hr_current_user, attrs: attrs, reason: reason)
|
|
54
55
|
return redirect_to(admin_leave_request_path(@request), notice: "Leave fixed.")
|
|
55
56
|
end
|
|
56
57
|
|
|
@@ -87,6 +88,21 @@ module HrLite
|
|
|
87
88
|
end
|
|
88
89
|
end
|
|
89
90
|
|
|
91
|
+
# One day out of approved full-day leave (they came in), from the attendance Fix panel.
|
|
92
|
+
def cancel_day
|
|
93
|
+
request = find_decidable
|
|
94
|
+
date = parse_date_param(params[:date])
|
|
95
|
+
note = params[:decision_note].to_s.strip
|
|
96
|
+
alert = if note.blank? then "A reason is required."
|
|
97
|
+
elsif HrLite.outranked?(hr_current_user, request.decided_by_id) then HrLite::ADMIN_DECIDED
|
|
98
|
+
elsif !(request.approved? && !request.half_day && date.between?(request.start_date, request.end_date) &&
|
|
99
|
+
request.cancellable_by?(hr_current_user)) then "That day cannot be taken out of this leave."
|
|
100
|
+
end
|
|
101
|
+
request.cancel_day!(date, actor: hr_current_user, note: note) unless alert
|
|
102
|
+
redirect_to admin_attendance_path(request.user_id, date: date, month: date.strftime("%Y-%m")),
|
|
103
|
+
alert ? { alert: alert } : { notice: "#{date.strftime('%d %b')} taken out of leave. The day is back in the balance." }
|
|
104
|
+
end
|
|
105
|
+
|
|
90
106
|
def reject
|
|
91
107
|
request = find_decidable
|
|
92
108
|
note = params[:decision_note].to_s.strip
|
|
@@ -69,6 +69,7 @@ module HrLite
|
|
|
69
69
|
request = decidable.where(status: %w[approved rejected]).find(params[:id])
|
|
70
70
|
note = params[:decision_note].to_s.strip
|
|
71
71
|
return redirect_to admin_wfh_request_path(request), alert: "A reason is required to change WFH." if note.blank?
|
|
72
|
+
return redirect_to admin_wfh_request_path(request), alert: HrLite::ADMIN_DECIDED if HrLite.outranked?(hr_current_user, request.decided_by_id)
|
|
72
73
|
|
|
73
74
|
request.change!(actor: hr_current_user, start_date: params[:start_date], end_date: params[:end_date],
|
|
74
75
|
status: params[:status].presence_in(%w[approved rejected]) || request.status, note: note)
|
|
@@ -49,5 +49,11 @@ module HrLite
|
|
|
49
49
|
end
|
|
50
50
|
"#{why} So #{date.strftime('%a %-d %b')} needs #{minutes} min more before checkout."
|
|
51
51
|
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# The puncher creates these on every late check-in; telling leadership each time cost ~1 s per punch.
|
|
56
|
+
# The audit row stays, and an HR removal (update) still notifies.
|
|
57
|
+
def hr_lite_publish_policy_change(log) = (super unless log.action == "create")
|
|
52
58
|
end
|
|
53
59
|
end
|
|
@@ -102,7 +102,7 @@ module HrLite
|
|
|
102
102
|
return false unless pending? || approved?
|
|
103
103
|
return pending? || start_date > Date.current if user_id == actor.id
|
|
104
104
|
|
|
105
|
-
HrLite.admin?(actor) || HrLite.leadership?(actor)
|
|
105
|
+
(HrLite.admin?(actor) || HrLite.leadership?(actor)) && !HrLite.outranked?(actor, decided_by_id)
|
|
106
106
|
end
|
|
107
107
|
|
|
108
108
|
def cancel!(actor:, note: nil)
|
|
@@ -132,6 +132,36 @@ module HrLite
|
|
|
132
132
|
true
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
# The person came in on a day of approved leave: HR takes that day out.
|
|
136
|
+
# One day is a cancel; otherwise the leave shrinks, or splits around a
|
|
137
|
+
# middle day (paid days go to the earlier part first). `used` is
|
|
138
|
+
# computed, so the day goes back to the balance.
|
|
139
|
+
def cancel_day!(date, actor:, note:)
|
|
140
|
+
return cancel!(actor: actor, note: note) if start_date == end_date
|
|
141
|
+
|
|
142
|
+
transaction do
|
|
143
|
+
last = end_date
|
|
144
|
+
if date == start_date then self.start_date = date + 1
|
|
145
|
+
else
|
|
146
|
+
self.end_date = date - 1
|
|
147
|
+
if date < last
|
|
148
|
+
later = dup.tap { |r| r.assign_attributes(start_date: date + 1, end_date: last) }
|
|
149
|
+
kept = paid_days && [ paid_days, LeaveDayCounter.count(self) ].min
|
|
150
|
+
self.class.insert!(later.attributes.except("id", "created_at", "updated_at")
|
|
151
|
+
.merge("days_count" => LeaveDayCounter.count(later), "paid_days" => kept && paid_days - kept))
|
|
152
|
+
self.paid_days = kept
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
self.days_count = LeaveDayCounter.count(self)
|
|
156
|
+
save!
|
|
157
|
+
audit!("day_cancelled", actor, "#{date}: #{note}")
|
|
158
|
+
flag_computed_runs!
|
|
159
|
+
end
|
|
160
|
+
Notifications.publish("leave.cancelled", title: "HR took #{date.strftime('%d %b')} out of your leave", body: note,
|
|
161
|
+
path: "/leave_requests", bell_to: [ user ])
|
|
162
|
+
true
|
|
163
|
+
end
|
|
164
|
+
|
|
135
165
|
# HR fixes leave it recorded — any time, even past dates — without a
|
|
136
166
|
# cancel. Re-run as a fresh recording so the create checks and the
|
|
137
167
|
# balance / loss-of-pay split are worked out exactly as the first time.
|
|
@@ -141,7 +141,7 @@ module HrLite
|
|
|
141
141
|
|
|
142
142
|
# A request HR has not decided yet still lets the person work; the day is flagged for HR.
|
|
143
143
|
@wfh = WfhRequest.approved_for(@user.id, Date.current) || WfhRequest.pending_for(@user.id, Date.current)
|
|
144
|
-
return OFF_SITE_MESSAGE unless @wfh || profile&.permanent_wfh
|
|
144
|
+
return OFF_SITE_MESSAGE unless @wfh || profile&.permanent_wfh || HrLite.config.wfh_weekdays.include?(Date.current.wday)
|
|
145
145
|
|
|
146
146
|
@mode, @place = "wfh", "home"
|
|
147
147
|
return if (@location = covering_home)
|
|
@@ -53,6 +53,7 @@ module HrLite
|
|
|
53
53
|
def call
|
|
54
54
|
return fail!("Pick a work mode.") unless SELECTABLE.include?(@mode)
|
|
55
55
|
return fail!("A reason is required for an HR change.") if @source == "hr" && @p[:reason].blank?
|
|
56
|
+
return fail!(HrLite::ADMIN_DECIDED) if @source == "hr" && HrLite.outranked?(@actor, open_setter_id)
|
|
56
57
|
|
|
57
58
|
status = WorkStatus.for(@user.id, at: @now)
|
|
58
59
|
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)
|
|
@@ -73,6 +74,8 @@ module HrLite
|
|
|
73
74
|
|
|
74
75
|
def fail!(message) = Result.new(nil, message)
|
|
75
76
|
|
|
77
|
+
def open_setter_id = WorkModeEvent.open.where(user_id: @user.id, layer: WorkModeEvent::MODES.dig(@mode, 1)).pick(:set_by_id)
|
|
78
|
+
|
|
76
79
|
def checked_in?
|
|
77
80
|
@record = AttendanceRecord.find_by(user_id: @user.id, date: @now.in_time_zone(HrLite.config.time_zone).to_date)
|
|
78
81
|
@record&.check_in_at.present? && @record.check_out_at.nil?
|
|
@@ -83,7 +86,7 @@ module HrLite
|
|
|
83
86
|
def office_attrs = base
|
|
84
87
|
|
|
85
88
|
def wfh_attrs
|
|
86
|
-
approved = WfhRequest.approved_for(@user.id, @record.date) ||
|
|
89
|
+
approved = WfhRequest.approved_for(@user.id, @record.date) || HrLite.config.wfh_weekdays.include?(@record.date.wday) ||
|
|
87
90
|
EmployeeProfile.where(user_id: @user.id).pick(:permanent_wfh)
|
|
88
91
|
return "WFH needs an approved WFH request for today." unless approved
|
|
89
92
|
|
|
@@ -60,7 +60,21 @@
|
|
|
60
60
|
<% end %>
|
|
61
61
|
|
|
62
62
|
<% if @edit_date %>
|
|
63
|
-
|
|
63
|
+
<div id="regularize">
|
|
64
|
+
<% if @edit_leave %>
|
|
65
|
+
<section class="hrl-card">
|
|
66
|
+
<h2 class="hrl-card__title">Checked in on leave</h2>
|
|
67
|
+
<%= form_with url: hr_lite.cancel_day_admin_leave_request_path(@edit_leave, date: @edit_date), method: :post, local: true do %>
|
|
68
|
+
<p>Approved <%= @edit_leave.leave_type.name %>, <%= @edit_leave.date_range_label %>.</p>
|
|
69
|
+
<div class="hrl-field">
|
|
70
|
+
<label for="cancel_day_note">Why take this day out of the leave? (required)</label>
|
|
71
|
+
<textarea name="decision_note" id="cancel_day_note" class="hrl-input" required></textarea>
|
|
72
|
+
</div>
|
|
73
|
+
<button type="submit" class="hrl-btn hrl-btn--primary">Take <%= @edit_date.strftime("%d %b") %> out of leave</button>
|
|
74
|
+
<% end %>
|
|
75
|
+
</section>
|
|
76
|
+
<% end %>
|
|
77
|
+
<section class="hrl-card">
|
|
64
78
|
<h2 class="hrl-card__title">Fix <%= @edit_date.strftime("%A, %d %B") %></h2>
|
|
65
79
|
<% if @edit_record.flagged? %>
|
|
66
80
|
<p class="hrl-small"><span class="hrl-badge hrl-badge--bad">Flagged</span> <%= @edit_record.flag_note %></p>
|
|
@@ -99,4 +113,5 @@
|
|
|
99
113
|
<% end %>
|
|
100
114
|
<% end %>
|
|
101
115
|
</section>
|
|
116
|
+
</div>
|
|
102
117
|
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -91,7 +91,7 @@ HrLite::Engine.routes.draw do
|
|
|
91
91
|
member { get :summary; patch :shorter_day; post :slot; post :unlock_slot; post :remove_penalty; post :remove_penalty_hours }
|
|
92
92
|
end
|
|
93
93
|
resources :leave_requests, only: %i[index show new create edit update] do
|
|
94
|
-
member { post :approve; post :reject; post :cancel }
|
|
94
|
+
member { post :approve; post :reject; post :cancel; post :cancel_day }
|
|
95
95
|
end
|
|
96
96
|
resources :leave_balances, only: :index do
|
|
97
97
|
collection { post :adjust }
|
|
@@ -24,6 +24,8 @@ module HrLite
|
|
|
24
24
|
:attendance_slots,
|
|
25
25
|
# true = check-in only inside an active office radius, unless WFH is approved for the day
|
|
26
26
|
:office_geofence,
|
|
27
|
+
# e.g. [6] — weekdays (Date#wday) that are WFH for everyone, like a standing approval
|
|
28
|
+
:wfh_weekdays,
|
|
27
29
|
# ->(user, from, to) { [{ id:, title:, starts_at:, ends_at: }] } — timed,
|
|
28
30
|
# accepted events; starts Meeting mode by itself. Pairs with calendar_users.
|
|
29
31
|
:calendar_events,
|
|
@@ -130,6 +132,7 @@ module HrLite
|
|
|
130
132
|
@require_punch_location = false
|
|
131
133
|
@attendance_slots = false
|
|
132
134
|
@office_geofence = false
|
|
135
|
+
@wfh_weekdays = []
|
|
133
136
|
@hiring = nil
|
|
134
137
|
@read_sheet = ->(path, filename) {
|
|
135
138
|
raise ArgumentError, "Upload the sheet as a .csv file." unless filename.to_s.downcase.end_with?(".csv")
|
data/lib/hr_lite/version.rb
CHANGED
data/lib/hr_lite.rb
CHANGED
|
@@ -25,6 +25,7 @@ module HrLite
|
|
|
25
25
|
|
|
26
26
|
# Full day / first half / second half — one picker for leave and attendance.
|
|
27
27
|
DAY_PARTS = [ [ "Full day", "" ], [ "First half", "first" ], [ "Second half", "second" ] ].freeze
|
|
28
|
+
ADMIN_DECIDED = "An admin decided this. Only an admin can change it.".freeze
|
|
28
29
|
class << self
|
|
29
30
|
def config
|
|
30
31
|
@config ||= Configuration.new
|
|
@@ -85,6 +86,14 @@ module HrLite
|
|
|
85
86
|
can?(user, "payroll.manage", scope: :all)
|
|
86
87
|
end
|
|
87
88
|
|
|
89
|
+
# An admin (role.manage) decision stands: anyone else, HR included, is
|
|
90
|
+
# outranked by it; an admin may change anyone's.
|
|
91
|
+
def outranked?(actor, decider_id)
|
|
92
|
+
return false if decider_id.blank? || can?(actor, "role.manage", scope: :all)
|
|
93
|
+
|
|
94
|
+
(decider = user_klass.find_by(id: decider_id)).present? && can?(decider, "role.manage", scope: :all)
|
|
95
|
+
end
|
|
96
|
+
|
|
88
97
|
# An access list, cleaned — read now only by the 0.6.0 upgrade migration.
|
|
89
98
|
# One stray comma in an ENV var ("a@x.com,,b@x.com") used to put "" in the
|
|
90
99
|
# list, and a user whose email was blank then MATCHED it and was handed
|