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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b0957999e3f167132b2c25455182bbe1aeb986ba6aaa9928d54721692a2c07a
4
- data.tar.gz: 0b60cf956f6ce373fab9300cbfbf32a2c3734a3bec9a19ff55d85fff13c91e7f
3
+ metadata.gz: ab802b6ea743100ee2edae138c1af298d2bf94e479c7b89318f573830eb0bd7f
4
+ data.tar.gz: 65f656038862fe47a5504839487118a2a13184ae129ed3ee5ddff8b2342ad066
5
5
  SHA512:
6
- metadata.gz: 2ab5be9b791abad3013fddfeda2472bf333af6d156d0627a627071e25026cc9d30a9018927002be3d8bbffa84960bba0c05251e0fe31781d11ed44f4857148d7
7
- data.tar.gz: 8b76610a5c37c3e5190aed833df733944b4e19716d4f7077e8a99f4f9042dcc4692871d94de58bd02829bce59fb2007da1ace55503912cb81521dbe7e27b9645
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 reason.present? && @request.correct!(actor: hr_current_user, attrs: attrs, reason: reason)
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
- <section class="hrl-card" id="regularize">
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")
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.65.1"
2
+ VERSION = "0.68.1"
3
3
  end
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hr_lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.65.1
4
+ version: 0.68.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha