hr_lite 0.29.0 → 0.31.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 +14 -0
- data/app/controllers/hr_lite/admin/attendances_controller.rb +29 -0
- data/app/controllers/hr_lite/admin/employees_controller.rb +2 -1
- data/app/controllers/hr_lite/admin/wfh_requests_controller.rb +103 -0
- data/app/controllers/hr_lite/attendance_controller.rb +23 -0
- data/app/controllers/hr_lite/wfh_requests_controller.rb +45 -0
- data/app/helpers/hr_lite/application_helper.rb +2 -2
- data/app/jobs/hr_lite/attendance_close_job.rb +12 -0
- data/app/models/hr_lite/attendance_penalty.rb +30 -0
- data/app/models/hr_lite/attendance_record.rb +13 -2
- data/app/models/hr_lite/attendance_slot.rb +38 -0
- data/app/models/hr_lite/employee_profile.rb +11 -0
- data/app/models/hr_lite/wfh_request.rb +108 -0
- data/app/services/hr_lite/attendance_puncher.rb +54 -0
- data/app/views/hr_lite/admin/attendances/_slot.html.erb +24 -0
- data/app/views/hr_lite/admin/attendances/show.html.erb +6 -1
- data/app/views/hr_lite/admin/employees/_form.html.erb +14 -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 +91 -0
- data/app/views/hr_lite/admin/wfh_requests/show.html.erb +40 -0
- data/app/views/hr_lite/attendance/_penalties.html.erb +25 -0
- data/app/views/hr_lite/attendance/_punch_card.html.erb +9 -0
- data/app/views/hr_lite/attendance/_slot.html.erb +33 -0
- data/app/views/hr_lite/attendance/show.html.erb +4 -0
- data/app/views/hr_lite/team/show.html.erb +2 -1
- data/app/views/hr_lite/wfh_requests/index.html.erb +70 -0
- data/config/routes.rb +8 -0
- data/db/migrate/20261013090000_create_hr_lite_attendance_slots.rb +16 -0
- data/db/migrate/20261013090100_create_hr_lite_attendance_penalties.rb +19 -0
- data/db/migrate/20261013090200_add_buffer_to_hr_lite_employee_profiles.rb +7 -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/lib/hr_lite/configuration.rb +7 -1
- data/lib/hr_lite/notifications.rb +5 -1
- data/lib/hr_lite/version.rb +1 -1
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cc6f0b0eab85d612fdadaffc907d6b8d64eda04ed9fba81c5611ef37266916cf
|
|
4
|
+
data.tar.gz: 81a344480832392ca76230a465597bc085e6e155b063f7f823fc2348de9891cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e9dba6adb15c59d153c44e29e5bbba3e24df80b6c2f8caaa4383bc49b98ba052a30780a51fe70bbfaf989188b8642bdb3de096214595dbeb511c3da6faf4a642
|
|
7
|
+
data.tar.gz: b8c776a6b48436ba780f09f948a5233d7372dd5abb5ed540c10507cb26de5a0cd7ec4f0f1a7cd40c528c5af18c0c8bc49e0d6959418d3b0c0b2a769450703011
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.31.0] - 2026-10-13
|
|
11
|
+
|
|
12
|
+
- Office geofence (`config.office_geofence = true`, off by default). A check-in must come from inside an active office's radius (Admin → Offices), with a GPS fix at least that precise; otherwise it is refused with "You are not within the office location. Please request Work From Home (WFH) approval from HR." A denied or failed location says why. Check-out is unchanged.
|
|
13
|
+
- Work from home requests. Employees request one or more dates from today up to 30 days ahead, with a reason; a 30-day calendar marks pending, approved and rejected dates. Overlapping live requests are refused. HR approves or rejects (a remark is required to reject) from Approvals → WFH; nobody decides their own. Only an approved request covering today lets a check-in skip the geofence; the day's record links to it and shows "WFH". Submission and decisions are audited and notified; a notification failure never blocks either.
|
|
14
|
+
- HR gives WFH directly from the WFH board: approved at once, from today onwards, up to 60 days per grant. Audited (`wfh.granted`) and the employee is told.
|
|
15
|
+
- Permanent WFH: a profile switch HR sets. The person checks in from anywhere; a check-in inside an office radius is recorded as office, not WFH. The same holds for a date-approved WFH day. Attendance records carry `wfh` and show "WFH" only for remote check-ins.
|
|
16
|
+
- WFH board for HR: employees requested and approved, pending and rejected counts, who is home on a chosen date (approved requests and permanent WFH, each person once) with whether they checked in from home or the office, and every schedule, filtered by date range, name, department, status and WFH attendance.
|
|
17
|
+
|
|
18
|
+
## [0.30.0] - 2026-10-13
|
|
19
|
+
|
|
20
|
+
- Attendance slots (`config.attendance_slots = true`, off by default). Employees pick a check-in slot (09:00, 09:30, 10:00 or 10:30; checkout 8h30 later) for today, this week or this month. Saving locks it. HR sets or unlocks a slot from the employee's attendance page. No slot means 10:00.
|
|
21
|
+
- Late penalties. After 5 minutes of grace, each started half hour late adds 30 minutes to that day's checkout. A check-in after 11:00 also adds 1 hour to each of the next two working days, and one after 11:30 makes the day a half day (an approved regularization ticket lifts it). Leaving before the required checkout adds 30 minutes to the next working day (nightly close job). The punch card shows the slot, the required checkout, time left and any shortage, and My attendance lists the month's penalties. HR removes a penalty with a reason; the row is kept.
|
|
22
|
+
- Late buffer. HR gives an employee 1h or 2h with a reason (parental care, medical, health, other) on their profile. It delays the penalty clock. Colleagues see the category on the team board; the note is HR-only and encrypted.
|
|
23
|
+
|
|
10
24
|
## [0.29.0] - 2026-10-11
|
|
11
25
|
|
|
12
26
|
- ID card: Share button. Draws front and back side by side into one PNG and opens the phone's share sheet (WhatsApp, mail…); desktops download the PNG. The card photo is now served through the Active Storage proxy (same site), so it shows up in the image when storage redirects to a cloud bucket. Vendors html-to-image 1.11.11 (MIT).
|
|
@@ -111,6 +111,35 @@ module HrLite
|
|
|
111
111
|
end
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
+
# HR sets a locked slot from today for the period.
|
|
115
|
+
def slot
|
|
116
|
+
@employee = find_manageable_employee
|
|
117
|
+
period = params[:period].to_s
|
|
118
|
+
slot = AttendanceSlot.new(user_id: @employee.id, set_by_id: hr_current_user.id, period: period,
|
|
119
|
+
start_minute: params[:start_minute].to_i, starts_on: Date.current,
|
|
120
|
+
ends_on: AttendanceSlot.ends_on_for(period, Date.current))
|
|
121
|
+
flash_for = slot.save ? { notice: "Slot set to #{AttendanceSlot.label(slot.start_minute)}." } : { alert: slot.errors.full_messages.to_sentence }
|
|
122
|
+
redirect_to admin_attendance_path(@employee.id), flash_for
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Lets the employee pick a new slot themselves.
|
|
126
|
+
def unlock_slot
|
|
127
|
+
@employee = find_manageable_employee
|
|
128
|
+
AttendanceSlot.where(user_id: @employee.id, locked: true).where(ends_on: Date.current..).find_each { |s| s.update!(locked: false) }
|
|
129
|
+
redirect_to admin_attendance_path(@employee.id), notice: "Slot unlocked. #{hr_display_name(@employee)} can pick a new one."
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def remove_penalty
|
|
133
|
+
@employee = find_manageable_employee
|
|
134
|
+
penalty = AttendancePenalty.active.where(user_id: @employee.id).find(params[:penalty_id])
|
|
135
|
+
reason = params[:removal_reason].to_s.strip
|
|
136
|
+
month = penalty.date.strftime("%Y-%m")
|
|
137
|
+
return redirect_to admin_attendance_path(@employee.id, month: month), alert: "A reason is required." if reason.blank?
|
|
138
|
+
|
|
139
|
+
penalty.update!(removed_at: Time.current, removed_by_id: hr_current_user.id, removal_reason: reason)
|
|
140
|
+
redirect_to admin_attendance_path(@employee.id, month: month), notice: "Penalty removed."
|
|
141
|
+
end
|
|
142
|
+
|
|
114
143
|
private
|
|
115
144
|
|
|
116
145
|
# Viewing somebody's month and rewriting their punches are different
|
|
@@ -115,7 +115,8 @@ module HrLite
|
|
|
115
115
|
:user_id, :manager_id, :designation, :date_of_birth, :date_of_joining,
|
|
116
116
|
:date_of_exit, :probation_until, :department, :work_location, :pan_number, :pf_uan, :esi_number,
|
|
117
117
|
:bank_account_number, :bank_ifsc, :bank_name, :tax_regime, :declared_annual_deductions,
|
|
118
|
-
:fy_opening_gross, :fy_opening_tds, :blood_group, :emergency_contact
|
|
118
|
+
:fy_opening_gross, :fy_opening_tds, :blood_group, :emergency_contact,
|
|
119
|
+
:buffer_minutes, :buffer_reason, :buffer_note, :permanent_wfh
|
|
119
120
|
)
|
|
120
121
|
end
|
|
121
122
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# WFH board (counts, who is home on a date, every schedule) and the
|
|
4
|
+
# decision screen. Viewing is attendance.view, deciding attendance.manage.
|
|
5
|
+
class WfhRequestsController < BaseController
|
|
6
|
+
def index
|
|
7
|
+
@from = params[:from].present? ? parse_date_param(params[:from]) : Date.current
|
|
8
|
+
@to = [ params[:to].present? ? parse_date_param(params[:to]) : @from + WfhRequest::MAX_DAYS_AHEAD, @from ].max
|
|
9
|
+
@on = params[:on].present? ? parse_date_param(params[:on]) : Date.current
|
|
10
|
+
@status = params[:status].presence_in(WfhRequest::STATUSES)
|
|
11
|
+
@attendance = params[:attendance].presence_in(%w[checked_in not_checked_in])
|
|
12
|
+
@departments = EmployeeProfile.where.not(department: [ nil, "" ]).distinct.order(:department).pluck(:department)
|
|
13
|
+
|
|
14
|
+
visible = hr_scope(WfhRequest.all, "attendance.view")
|
|
15
|
+
visible = visible.where(user_id: filtered_user_ids) if filtered_user_ids
|
|
16
|
+
range = visible.overlapping(@from, @to)
|
|
17
|
+
@kpis = {
|
|
18
|
+
requested: range.distinct.count(:user_id),
|
|
19
|
+
approved: range.approved.distinct.count(:user_id),
|
|
20
|
+
pending: range.pending.count,
|
|
21
|
+
rejected: range.where(status: "rejected").count
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
load_home(visible)
|
|
25
|
+
|
|
26
|
+
scoped = @status ? range.where(status: @status) : range
|
|
27
|
+
@requests = paginate(scoped.includes(:user).order(:user_id, :start_date, :id))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# HR gives WFH outright, approved on creation, up to 60 days.
|
|
31
|
+
def create
|
|
32
|
+
employee = HrLite.user_klass.find(params[:user_id])
|
|
33
|
+
hr_require_reach!("attendance.manage", employee)
|
|
34
|
+
raise ActiveRecord::RecordNotFound if employee.id == hr_current_user.id
|
|
35
|
+
|
|
36
|
+
request = WfhRequest.grant!(user_id: employee.id, by: hr_current_user, start_date: params[:start_date],
|
|
37
|
+
end_date: params[:end_date], reason: params[:reason].to_s.strip)
|
|
38
|
+
redirect_to admin_wfh_requests_path, notice: "WFH given to #{hr_display_name(employee)} for #{request.dates_label}."
|
|
39
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
40
|
+
redirect_to admin_wfh_requests_path, alert: "Could not give WFH: #{e.record.errors.full_messages.to_sentence}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def show
|
|
44
|
+
@request = hr_scope(WfhRequest.all, "attendance.view").find(params[:id])
|
|
45
|
+
@decidable = decidable.exists?(@request.id)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def approve
|
|
49
|
+
request = decidable.find(params[:id])
|
|
50
|
+
request.approve!(actor: hr_current_user, note: params[:decision_note].to_s.strip.presence)
|
|
51
|
+
redirect_to admin_wfh_requests_path, notice: "WFH approved for #{hr_display_name(request.user)}."
|
|
52
|
+
rescue ActiveRecord::RecordInvalid
|
|
53
|
+
redirect_to admin_wfh_request_path(request), alert: "Only pending requests can be decided."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def reject
|
|
57
|
+
request = decidable.find(params[:id])
|
|
58
|
+
note = params[:decision_note].to_s.strip
|
|
59
|
+
return redirect_to admin_wfh_request_path(request), alert: "A remark is required to reject." if note.blank?
|
|
60
|
+
|
|
61
|
+
request.reject!(actor: hr_current_user, note: note)
|
|
62
|
+
redirect_to admin_wfh_requests_path, notice: "WFH rejected."
|
|
63
|
+
rescue ActiveRecord::RecordInvalid
|
|
64
|
+
redirect_to admin_wfh_request_path(request), alert: "Only pending requests can be decided."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Nobody decides their own request; out-of-scope ids are a 404.
|
|
70
|
+
def decidable = hr_scope(WfhRequest.all, "attendance.manage").where.not(user_id: hr_current_user.id)
|
|
71
|
+
|
|
72
|
+
HomeRow = Struct.new(:user, :label, :request)
|
|
73
|
+
|
|
74
|
+
# Everyone home on @on: an approved request, else permanent WFH. One row a person.
|
|
75
|
+
def load_home(visible)
|
|
76
|
+
rows = visible.approved.overlapping(@on, @on).includes(:user).order(:user_id)
|
|
77
|
+
.map { |r| HomeRow.new(r.user, r.dates_label, r) }.uniq { |row| row.user.id }
|
|
78
|
+
permanent = hr_scope(EmployeeProfile.where(permanent_wfh: true), "attendance.view")
|
|
79
|
+
permanent = permanent.where(user_id: filtered_user_ids) if filtered_user_ids
|
|
80
|
+
ids = permanent.pluck(:user_id) - rows.map { |row| row.user.id }
|
|
81
|
+
rows += HrLite.user_klass.where(id: ids).map { |u| HomeRow.new(u, "Permanent WFH", nil) }
|
|
82
|
+
@records = AttendanceRecord.where(date: @on, user_id: rows.map { |row| row.user.id }).where.not(check_in_at: nil).index_by(&:user_id)
|
|
83
|
+
@home_count = rows.size
|
|
84
|
+
@home = @attendance ? rows.select { |row| @records.key?(row.user.id) == (@attendance == "checked_in") } : rows
|
|
85
|
+
@grantable = HrLite.employees.select { |u| u.id != hr_current_user.id && hr_reaches?("attendance.manage", u) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def filtered_user_ids
|
|
89
|
+
return @filtered_user_ids if defined?(@filtered_user_ids)
|
|
90
|
+
|
|
91
|
+
ids = nil
|
|
92
|
+
if (q = params[:q].to_s.strip.downcase).present?
|
|
93
|
+
ids = HrLite.employees.select { |u| HrLite.display_name(u).to_s.downcase.include?(q) }.map(&:id)
|
|
94
|
+
end
|
|
95
|
+
if (dept = params[:department].presence)
|
|
96
|
+
in_dept = EmployeeProfile.where(department: dept).pluck(:user_id)
|
|
97
|
+
ids = ids ? ids & in_dept : in_dept
|
|
98
|
+
end
|
|
99
|
+
@filtered_user_ids = ids
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -25,6 +25,29 @@ module HrLite
|
|
|
25
25
|
punch(:check_out)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Saving locks the slot for the period; a locked one only HR changes.
|
|
29
|
+
def slot
|
|
30
|
+
raise ActiveRecord::RecordNotFound unless HrLite.config.attendance_slots
|
|
31
|
+
|
|
32
|
+
period = params[:period].to_s
|
|
33
|
+
to = AttendanceSlot.ends_on_for(period, Date.current)
|
|
34
|
+
if AttendanceSlot.where(user_id: hr_current_user.id, locked: true).overlapping(Date.current, to).exists?
|
|
35
|
+
return redirect_to attendance_path, alert: "Your slot is locked for that period. Ask HR to change it."
|
|
36
|
+
end
|
|
37
|
+
# Today's penalty was priced on the slot at check-in; picking one now would rewrite it.
|
|
38
|
+
if AttendanceRecord.where(user_id: hr_current_user.id, date: Date.current).where.not(check_in_at: nil).exists?
|
|
39
|
+
return redirect_to attendance_path, alert: "Pick your slot before checking in."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
slot = AttendanceSlot.new(user_id: hr_current_user.id, set_by_id: hr_current_user.id, period: period,
|
|
43
|
+
start_minute: params[:start_minute].to_i, starts_on: Date.current, ends_on: to)
|
|
44
|
+
if slot.save
|
|
45
|
+
redirect_to attendance_path, notice: "Slot #{AttendanceSlot.label(slot.start_minute)} saved and locked until #{to.strftime('%d %b')}."
|
|
46
|
+
else
|
|
47
|
+
redirect_to attendance_path, alert: slot.errors.full_messages.to_sentence
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
28
51
|
private
|
|
29
52
|
|
|
30
53
|
def punch(kind)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Employee-side WFH requests. Scoping to hr_current_user IS the
|
|
3
|
+
# authorization; there is no edit, so a decided request cannot change.
|
|
4
|
+
class WfhRequestsController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@requests = paginate(WfhRequest.where(user_id: hr_current_user.id).recent_first)
|
|
7
|
+
@request = WfhRequest.new(start_date: Date.current, end_date: Date.current)
|
|
8
|
+
load_calendar
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def create
|
|
12
|
+
@request = WfhRequest.new(params.require(:wfh_request).permit(:start_date, :end_date, :reason)
|
|
13
|
+
.merge(user_id: hr_current_user.id, status: "pending"))
|
|
14
|
+
# Row lock on the user: two tabs can't both pass the overlap check.
|
|
15
|
+
saved = WfhRequest.transaction { HrLite.user_klass.lock.find(hr_current_user.id) && @request.save }
|
|
16
|
+
if saved
|
|
17
|
+
@request.notify_requested
|
|
18
|
+
redirect_to wfh_requests_path, notice: "WFH request sent to HR."
|
|
19
|
+
else
|
|
20
|
+
@requests = paginate(WfhRequest.where(user_id: hr_current_user.id).recent_first)
|
|
21
|
+
load_calendar
|
|
22
|
+
render :index, status: :unprocessable_entity
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def cancel
|
|
27
|
+
WfhRequest.where(user_id: hr_current_user.id).find(params[:id]).cancel!(actor: hr_current_user)
|
|
28
|
+
redirect_to wfh_requests_path, notice: "WFH request cancelled."
|
|
29
|
+
rescue ActiveRecord::RecordInvalid
|
|
30
|
+
redirect_to wfh_requests_path, alert: "Only pending requests can be cancelled."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Status per date for the next 30 days; a later request wins a shared date.
|
|
36
|
+
def load_calendar
|
|
37
|
+
@window = Date.current..(Date.current + WfhRequest::MAX_DAYS_AHEAD)
|
|
38
|
+
@day_status = {}
|
|
39
|
+
WfhRequest.where(user_id: hr_current_user.id, status: %w[pending approved rejected])
|
|
40
|
+
.overlapping(@window.first, @window.last).order(:id).each do |request|
|
|
41
|
+
(request.start_date..request.end_date).each { |d| @day_status[d] = request.status if @window.cover?(d) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -2,7 +2,7 @@ module HrLite
|
|
|
2
2
|
module ApplicationHelper
|
|
3
3
|
NAV_ITEMS = [
|
|
4
4
|
{ label: "Home", path: :root_path, match: [ "/" ], group: "My work" },
|
|
5
|
-
{ label: "Attendance", path: :attendance_path, match: [ "/attendance", "/regularization_requests" ], group: "My work" },
|
|
5
|
+
{ label: "Attendance", path: :attendance_path, match: [ "/attendance", "/regularization_requests", "/wfh_requests" ], group: "My work" },
|
|
6
6
|
{ label: "Leaves", path: :leave_requests_path, match: [ "/leave_requests", "/leave_balances", "/comp_off_requests" ], group: "My work" },
|
|
7
7
|
{ label: "Team", path: :team_path, match: [ "/team" ], group: "My work" },
|
|
8
8
|
# The phone tab bar shows the first four; the rest fill the More sheet,
|
|
@@ -33,7 +33,7 @@ module HrLite
|
|
|
33
33
|
ADMIN_NAV_ITEMS = [
|
|
34
34
|
{ label: "Overview", path: :admin_overview_path, match: [ "/admin/overview" ] },
|
|
35
35
|
{ label: "Team attendance", path: :admin_attendances_path, match: [ "/admin/attendances" ] },
|
|
36
|
-
{ label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests" ] },
|
|
36
|
+
{ label: "Approvals", path: :admin_leave_requests_path, match: [ "/admin/leave_requests", "/admin/leave_balances", "/admin/comp_off_requests", "/admin/regularization_requests", "/admin/wfh_requests" ] },
|
|
37
37
|
{ label: "Reports", path: :admin_reports_path, match: [ "/admin/reports" ] },
|
|
38
38
|
{ label: "Claims", path: :admin_expenses_path, match: [ "/admin/expenses" ],
|
|
39
39
|
if: -> { hr_can?("expense.approve", scope: :team) } },
|
|
@@ -27,11 +27,23 @@ module HrLite
|
|
|
27
27
|
tell(user, date, "attendance.short_day",
|
|
28
28
|
"You worked #{(record.worked_duration / 3600.0).round(1)} of #{record.required_hours}h on #{day} — marked half day")
|
|
29
29
|
end
|
|
30
|
+
penalize_short_day(record) if record&.check_out_at
|
|
30
31
|
end
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
private
|
|
34
35
|
|
|
36
|
+
# Out before the slot's required checkout: +30 min on the next working day.
|
|
37
|
+
def penalize_short_day(record)
|
|
38
|
+
return unless HrLite.config.attendance_slots && record.check_out_at < record.required_check_out_at
|
|
39
|
+
|
|
40
|
+
next_day = AttendancePenalty.next_working_days(record.date, 1).first
|
|
41
|
+
AttendancePenalty.find_or_create_by!(attendance_record: record, reason: "short_day", date: next_day) do |p|
|
|
42
|
+
p.user_id = record.user_id
|
|
43
|
+
p.minutes = AttendanceSlot::STEP_MINUTES
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
35
47
|
def tell(user, date, event, title)
|
|
36
48
|
rule = HrLite.config.self_regularization
|
|
37
49
|
body = if rule
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Minutes added to one day's required checkout. Never deleted: HR removes
|
|
3
|
+
# one with a reason and the row stays as the record.
|
|
4
|
+
class AttendancePenalty < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
REASONS = {
|
|
8
|
+
"late" => "Late check-in",
|
|
9
|
+
"late_carry" => "Checked in after 11:00",
|
|
10
|
+
"short_day" => "Left before the required checkout"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
14
|
+
belongs_to :attendance_record
|
|
15
|
+
belongs_to :removed_by, class_name: HrLite.config.user_class, optional: true
|
|
16
|
+
|
|
17
|
+
validates :reason, inclusion: { in: REASONS.keys }
|
|
18
|
+
validates :minutes, numericality: { greater_than: 0 }
|
|
19
|
+
validates :removal_reason, presence: true, if: :removed_at
|
|
20
|
+
|
|
21
|
+
scope :active, -> { where(removed_at: nil) }
|
|
22
|
+
|
|
23
|
+
def self.next_working_days(date, count)
|
|
24
|
+
calendar = WorkingCalendar.new((date + 1)..(date + 31))
|
|
25
|
+
((date + 1)..(date + 31)).select { |d| calendar.working_day?(d) }.first(count)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def label = REASONS.fetch(reason)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -3,13 +3,15 @@ module HrLite
|
|
|
3
3
|
STATUSES = %w[present half_day].freeze
|
|
4
4
|
AUTO_CHECKOUT_NOTE = "Auto check-out: no check-out punched".freeze
|
|
5
5
|
SHORT_DAY_NOTE = "Short day: under the required hours".freeze
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
LATE_HALF_DAY_NOTE = "Half day: checked in after 11:30".freeze
|
|
7
|
+
# Half days the system set (not HR); an approved ticket lifts them.
|
|
8
|
+
AUTO_NOTES = [ AUTO_CHECKOUT_NOTE, SHORT_DAY_NOTE, LATE_HALF_DAY_NOTE ].freeze
|
|
8
9
|
# Check-in and check-out further apart than this are called out on the board.
|
|
9
10
|
PUNCH_GAP_ALERT_M = 500
|
|
10
11
|
|
|
11
12
|
belongs_to :user, class_name: HrLite.config.user_class
|
|
12
13
|
belongs_to :regularized_by, class_name: HrLite.config.user_class, optional: true
|
|
14
|
+
belongs_to :wfh_request, optional: true
|
|
13
15
|
|
|
14
16
|
validates :date, presence: true, uniqueness: { scope: :user_id }
|
|
15
17
|
validates :status, inclusion: { in: STATUSES }
|
|
@@ -45,6 +47,15 @@ module HrLite
|
|
|
45
47
|
!!(hours && worked_duration && worked_duration < hours.hours)
|
|
46
48
|
end
|
|
47
49
|
|
|
50
|
+
# config.attendance_slots: slot start + 8h30 + the day's active penalties.
|
|
51
|
+
def required_check_out_at
|
|
52
|
+
return unless HrLite.config.attendance_slots && check_in_at
|
|
53
|
+
|
|
54
|
+
extra = AttendancePenalty.active.where(user_id: user_id, date: date).sum(:minutes)
|
|
55
|
+
date.in_time_zone(HrLite.config.time_zone) +
|
|
56
|
+
(AttendanceSlot.start_minute_for(user_id, date) + AttendanceSlot::DAY_MINUTES + extra).minutes
|
|
57
|
+
end
|
|
58
|
+
|
|
48
59
|
# Metres between where the day was checked in and checked out.
|
|
49
60
|
def punch_gap_m
|
|
50
61
|
return unless check_in_lat && check_in_lng && check_out_lat && check_out_lng
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The check-in slot an employee commits to for a day, week or month.
|
|
3
|
+
# Saving locks it; only an attendance manager changes or unlocks it.
|
|
4
|
+
class AttendanceSlot < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
START_MINUTES = [ 540, 570, 600, 630 ].freeze # 09:00 09:30 10:00 10:30
|
|
8
|
+
DEFAULT_START_MINUTE = 600
|
|
9
|
+
PERIODS = { "day" => "Today", "week" => "This week", "month" => "This month" }.freeze
|
|
10
|
+
DAY_MINUTES = 510 # 8h30m from slot to checkout
|
|
11
|
+
GRACE_MINUTES = 5
|
|
12
|
+
STEP_MINUTES = 30
|
|
13
|
+
CARRY_AFTER_MINUTE = 660 # 11:00 — +1h on the next 2 working days
|
|
14
|
+
HALF_DAY_AFTER_MINUTE = 690 # 11:30 — the day is a half day
|
|
15
|
+
|
|
16
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
17
|
+
|
|
18
|
+
validates :start_minute, inclusion: { in: START_MINUTES }
|
|
19
|
+
validates :period, inclusion: { in: PERIODS.keys }
|
|
20
|
+
validates :starts_on, :ends_on, presence: true
|
|
21
|
+
|
|
22
|
+
scope :overlapping, ->(from, to) { where("starts_on <= ? AND ends_on >= ?", to, from) }
|
|
23
|
+
|
|
24
|
+
def self.current(user_id, date) = where(user_id: user_id).overlapping(date, date).order(:created_at).last
|
|
25
|
+
def self.start_minute_for(user_id, date) = current(user_id, date)&.start_minute || DEFAULT_START_MINUTE
|
|
26
|
+
|
|
27
|
+
def self.ends_on_for(period, from)
|
|
28
|
+
{ "week" => from.end_of_week(:monday), "month" => from.end_of_month }.fetch(period, from)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 0 within grace, then +30 for every started half hour: 6–35 late = 30, 36–65 = 60.
|
|
32
|
+
def self.penalty_minutes(late)
|
|
33
|
+
late <= GRACE_MINUTES ? 0 : STEP_MINUTES * ((late - GRACE_MINUTES) / STEP_MINUTES.to_f).ceil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.label(minute) = format("%02d:%02d", *minute.divmod(60))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -20,6 +20,17 @@ module HrLite
|
|
|
20
20
|
# read as zero income for the months it cannot see.
|
|
21
21
|
encrypted_money :declared_annual_deductions, :fy_opening_gross, :fy_opening_tds
|
|
22
22
|
|
|
23
|
+
# HR-granted late allowance; it delays the penalty clock. Colleagues see
|
|
24
|
+
# the category, only HR sees the note (encrypted: it can be medical).
|
|
25
|
+
BUFFER_MINUTES = [ 60, 120 ].freeze
|
|
26
|
+
BUFFER_REASONS = { "parental_care" => "Parental care", "medical" => "Medical",
|
|
27
|
+
"health" => "Health", "other" => "Other" }.freeze
|
|
28
|
+
encrypts :buffer_note
|
|
29
|
+
normalizes :buffer_minutes, with: ->(v) { v.presence }
|
|
30
|
+
validates :buffer_minutes, inclusion: { in: BUFFER_MINUTES }, allow_nil: true
|
|
31
|
+
validates :buffer_reason, inclusion: { in: BUFFER_REASONS.keys }, if: :buffer_minutes
|
|
32
|
+
validates :buffer_note, presence: true, if: -> { buffer_minutes && buffer_reason == "other" }
|
|
33
|
+
|
|
23
34
|
TAX_REGIMES = %w[new old].freeze
|
|
24
35
|
BLOOD_GROUPS = %w[A+ A- B+ B- AB+ AB- O+ O-].freeze
|
|
25
36
|
normalizes :blood_group, with: ->(v) { v.presence } # the "Not set" chip posts ""
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "Let me work from home on these dates." HR approves or rejects; only an
|
|
3
|
+
# approved request covering today lets a check-in skip the office geofence.
|
|
4
|
+
class WfhRequest < ApplicationRecord
|
|
5
|
+
STATUSES = %w[pending approved rejected cancelled].freeze
|
|
6
|
+
MAX_DAYS_AHEAD = 30
|
|
7
|
+
HR_MAX_DAYS = 60 # a span HR grants directly
|
|
8
|
+
|
|
9
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
10
|
+
belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
|
|
11
|
+
belongs_to :created_by, class_name: HrLite.config.user_class, optional: true
|
|
12
|
+
|
|
13
|
+
validates :start_date, :end_date, :reason, presence: true
|
|
14
|
+
validates :status, inclusion: { in: STATUSES }
|
|
15
|
+
validate :dates_in_window, :no_overlap, on: :create
|
|
16
|
+
|
|
17
|
+
scope :pending, -> { where(status: "pending") }
|
|
18
|
+
scope :approved, -> { where(status: "approved") }
|
|
19
|
+
scope :live, -> { where(status: %w[pending approved]) }
|
|
20
|
+
scope :overlapping, ->(from, to) { where("start_date <= ? AND end_date >= ?", to, from) }
|
|
21
|
+
scope :recent_first, -> { order(start_date: :desc, id: :desc) }
|
|
22
|
+
|
|
23
|
+
STATUSES.each { |s| define_method("#{s}?") { status == s } }
|
|
24
|
+
|
|
25
|
+
def self.approved_for(user_id, date) = approved.where(user_id: user_id).overlapping(date, date).first
|
|
26
|
+
|
|
27
|
+
# HR grants WFH outright: approved on creation, up to HR_MAX_DAYS long.
|
|
28
|
+
def self.grant!(user_id:, by:, start_date:, end_date:, reason:)
|
|
29
|
+
request = create!(user_id: user_id, start_date: start_date, end_date: end_date, reason: reason,
|
|
30
|
+
status: "approved", created_by_id: by.id, decided_by_id: by.id, decided_at: Time.current)
|
|
31
|
+
AuditLog.record!(action: "wfh.granted", subject: request, actor: by,
|
|
32
|
+
changes: { "dates" => request.dates_label, "reason" => reason })
|
|
33
|
+
request.send(:announce, "wfh.approved", "HR gave you WFH — #{request.dates_label}", reason, "/wfh_requests", [ request.user ])
|
|
34
|
+
request
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def granted? = created_by_id.present? && created_by_id != user_id
|
|
38
|
+
|
|
39
|
+
def dates_label
|
|
40
|
+
start_date == end_date ? start_date.strftime("%a %-d %b") : "#{start_date.strftime('%-d %b')} – #{end_date.strftime('%-d %b')}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def days = (end_date - start_date).to_i + 1
|
|
44
|
+
|
|
45
|
+
def approve!(actor:, note: nil)
|
|
46
|
+
transition!("approved", actor, note)
|
|
47
|
+
announce("wfh.approved", "WFH approved — #{dates_label}", decision_note, "/wfh_requests", [ user ])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def reject!(actor:, note:)
|
|
51
|
+
transition!("rejected", actor, note)
|
|
52
|
+
announce("wfh.rejected", "WFH rejected — #{dates_label}", decision_note, "/wfh_requests", [ user ])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cancel!(actor:)
|
|
56
|
+
transition!("cancelled", actor, nil)
|
|
57
|
+
announce("wfh.cancelled", "#{HrLite.display_name(user)} cancelled WFH for #{dates_label}", nil,
|
|
58
|
+
"/admin/wfh_requests/#{id}", HrLite.admin_users)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def notify_requested
|
|
62
|
+
AuditLog.record!(action: "wfh.requested", subject: self, actor: user,
|
|
63
|
+
changes: { "dates" => dates_label, "reason" => reason })
|
|
64
|
+
announce("wfh.requested", "#{HrLite.display_name(user)} requested WFH for #{dates_label}", reason,
|
|
65
|
+
"/admin/wfh_requests/#{id}", HrLite.admin_users)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def transition!(new_status, actor, note)
|
|
71
|
+
with_lock do
|
|
72
|
+
raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending?
|
|
73
|
+
|
|
74
|
+
update!(status: new_status, decided_by_id: actor.id, decided_at: Time.current, decision_note: note)
|
|
75
|
+
end
|
|
76
|
+
AuditLog.record!(action: "wfh.#{new_status}", subject: self, actor: actor,
|
|
77
|
+
changes: { "dates" => dates_label, "note" => note }.compact)
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A notification failure never undoes a request or a decision.
|
|
82
|
+
def announce(event, title, body, path, to)
|
|
83
|
+
Notifications.publish(event, title: title, body: body.presence, path: path, bell_to: to,
|
|
84
|
+
email_to: event == "wfh.requested" ? [] : to)
|
|
85
|
+
rescue => e
|
|
86
|
+
Rails.logger.error("[hr_lite] wfh notification failed: #{e.class}: #{e.message}")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def dates_in_window
|
|
90
|
+
return unless start_date && end_date
|
|
91
|
+
|
|
92
|
+
errors.add(:start_date, "cannot be in the past") if start_date < Date.current
|
|
93
|
+
errors.add(:end_date, "must be on or after the start date") if end_date < start_date
|
|
94
|
+
if granted?
|
|
95
|
+
errors.add(:end_date, "HR can grant at most #{HR_MAX_DAYS} days at a time") if days > HR_MAX_DAYS
|
|
96
|
+
elsif end_date > Date.current + MAX_DAYS_AHEAD
|
|
97
|
+
errors.add(:end_date, "must be within #{MAX_DAYS_AHEAD} days from today")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def no_overlap
|
|
102
|
+
return unless start_date && end_date && user_id
|
|
103
|
+
|
|
104
|
+
clash = self.class.live.where(user_id: user_id).overlapping(start_date, end_date).exists?
|
|
105
|
+
errors.add(:base, "You already have a pending or approved WFH request on some of these dates") if clash
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -6,6 +6,7 @@ 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
|
|
9
10
|
|
|
10
11
|
Result = Struct.new(:record, :error, keyword_init: true) do
|
|
11
12
|
def ok? = error.nil?
|
|
@@ -34,6 +35,10 @@ module HrLite
|
|
|
34
35
|
"or raise a regularization ticket.")
|
|
35
36
|
end
|
|
36
37
|
|
|
38
|
+
if (error = geofence_error)
|
|
39
|
+
return Result.new(error: error)
|
|
40
|
+
end
|
|
41
|
+
|
|
37
42
|
record = resolve_record
|
|
38
43
|
return Result.new(error: "No open check-in to close.") if record.nil?
|
|
39
44
|
|
|
@@ -43,6 +48,8 @@ module HrLite
|
|
|
43
48
|
return Result.new(record: record, error: already_in_message(record)) if record.check_in_at.present?
|
|
44
49
|
|
|
45
50
|
stamp(record, :check_in)
|
|
51
|
+
record.wfh = !!@remote
|
|
52
|
+
record.wfh_request_id = @wfh&.id if @remote
|
|
46
53
|
when :check_out
|
|
47
54
|
return Result.new(record: record, error: "Check in first.") if record.check_in_at.blank?
|
|
48
55
|
|
|
@@ -53,6 +60,7 @@ module HrLite
|
|
|
53
60
|
|
|
54
61
|
apply_geo_flags(record)
|
|
55
62
|
record.save!
|
|
63
|
+
apply_slot_rules(record) if @kind == :check_in && HrLite.config.attendance_slots
|
|
56
64
|
end
|
|
57
65
|
notify_if_flagged(record)
|
|
58
66
|
Result.new(record: record)
|
|
@@ -75,6 +83,31 @@ module HrLite
|
|
|
75
83
|
retry
|
|
76
84
|
end
|
|
77
85
|
|
|
86
|
+
# config.office_geofence: a check-in must be inside an office radius with
|
|
87
|
+
# a fix at least that precise, unless HR approved WFH for today or made
|
|
88
|
+
# the person permanent WFH. Inside the radius the day is office either way.
|
|
89
|
+
def geofence_error
|
|
90
|
+
return unless @kind == :check_in && HrLite.config.office_geofence && OfficeLocation.active.exists?
|
|
91
|
+
return if in_office?
|
|
92
|
+
|
|
93
|
+
@wfh = WfhRequest.approved_for(@user.id, Date.current)
|
|
94
|
+
@remote = @wfh.present? || EmployeeProfile.where(user_id: @user.id).pick(:permanent_wfh)
|
|
95
|
+
return if @remote
|
|
96
|
+
return OFF_SITE_MESSAGE if @lat && @lng
|
|
97
|
+
|
|
98
|
+
"We could not read your location (#{@geo_status}). Turn location on and try again, " \
|
|
99
|
+
"or request Work From Home (WFH) approval from HR."
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def in_office?
|
|
103
|
+
return false unless @lat && @lng && @accuracy_m.to_f.positive?
|
|
104
|
+
|
|
105
|
+
OfficeLocation.active.any? do |office|
|
|
106
|
+
@accuracy_m.to_f <= office.radius_m &&
|
|
107
|
+
Geo.distance_m(office.lat, office.lng, @lat.to_f, @lng.to_f) <= office.radius_m
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
78
111
|
def stamp(record, side)
|
|
79
112
|
record.public_send("#{side}_at=", Time.current)
|
|
80
113
|
record.public_send("#{side}_lat=", @lat)
|
|
@@ -86,12 +119,33 @@ module HrLite
|
|
|
86
119
|
"Already checked in at #{record.check_in_at.in_time_zone(HrLite.config.time_zone).strftime('%H:%M')}."
|
|
87
120
|
end
|
|
88
121
|
|
|
122
|
+
# Late ladder against slot + buffer; after 11:00 also +1h on the next two
|
|
123
|
+
# working days; after 11:30 the day is a half day. The buffer shifts all three.
|
|
124
|
+
def apply_slot_rules(record)
|
|
125
|
+
local = record.check_in_at.in_time_zone(HrLite.config.time_zone)
|
|
126
|
+
arrived = local.hour * 60 + local.min - EmployeeProfile.where(user_id: @user.id).pick(:buffer_minutes).to_i
|
|
127
|
+
late = AttendanceSlot.penalty_minutes(arrived - AttendanceSlot.start_minute_for(@user.id, record.date))
|
|
128
|
+
penalize(record, record.date, late, "late") if late.positive?
|
|
129
|
+
return if arrived <= AttendanceSlot::CARRY_AFTER_MINUTE
|
|
130
|
+
|
|
131
|
+
AttendancePenalty.next_working_days(record.date, 2).each { |day| penalize(record, day, 60, "late_carry") }
|
|
132
|
+
return if arrived <= AttendanceSlot::HALF_DAY_AFTER_MINUTE
|
|
133
|
+
|
|
134
|
+
record.update!(status: "half_day", regularization_note: AttendanceRecord::LATE_HALF_DAY_NOTE)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def penalize(record, date, minutes, reason)
|
|
138
|
+
AttendancePenalty.create!(user_id: @user.id, date: date, minutes: minutes, reason: reason, attendance_record: record)
|
|
139
|
+
end
|
|
140
|
+
|
|
89
141
|
def apply_geo_flags(record)
|
|
90
142
|
label = @kind == :check_in ? "Check-in" : "Check-out"
|
|
91
143
|
|
|
92
144
|
if @lat.nil? || @lng.nil?
|
|
93
145
|
reason = @geo_status == "ok" ? "unavailable" : @geo_status
|
|
94
146
|
record.add_flag!("#{label} without GPS (#{reason})")
|
|
147
|
+
elsif record.wfh
|
|
148
|
+
nil # WFH: off-site is the point
|
|
95
149
|
elsif OfficeLocation.active.exists? && !OfficeLocation.covering?(@lat, @lng)
|
|
96
150
|
office = OfficeLocation.nearest(@lat, @lng)
|
|
97
151
|
distance_km = (Geo.distance_m(office.lat, office.lng, @lat, @lng) / 1000.0).round(1)
|