hr_lite 0.30.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 +8 -0
- data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/wfh_requests_controller.rb +103 -0
- data/app/controllers/hr_lite/wfh_requests_controller.rb +45 -0
- data/app/helpers/hr_lite/application_helper.rb +2 -2
- data/app/models/hr_lite/attendance_record.rb +1 -0
- data/app/models/hr_lite/wfh_request.rb +108 -0
- data/app/services/hr_lite/attendance_puncher.rb +34 -0
- data/app/views/hr_lite/admin/attendances/show.html.erb +1 -1
- data/app/views/hr_lite/admin/employees/_form.html.erb +3 -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/_punch_card.html.erb +6 -0
- data/app/views/hr_lite/wfh_requests/index.html.erb +70 -0
- data/config/routes.rb +6 -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 +4 -1
- data/lib/hr_lite/notifications.rb +5 -1
- data/lib/hr_lite/version.rb +1 -1
- metadata +10 -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,14 @@ 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
|
+
|
|
10
18
|
## [0.30.0] - 2026-10-13
|
|
11
19
|
|
|
12
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.
|
|
@@ -116,7 +116,7 @@ module HrLite
|
|
|
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
118
|
:fy_opening_gross, :fy_opening_tds, :blood_group, :emergency_contact,
|
|
119
|
-
:buffer_minutes, :buffer_reason, :buffer_note
|
|
119
|
+
:buffer_minutes, :buffer_reason, :buffer_note, :permanent_wfh
|
|
120
120
|
)
|
|
121
121
|
end
|
|
122
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
|
|
@@ -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) } },
|
|
@@ -11,6 +11,7 @@ module HrLite
|
|
|
11
11
|
|
|
12
12
|
belongs_to :user, class_name: HrLite.config.user_class
|
|
13
13
|
belongs_to :regularized_by, class_name: HrLite.config.user_class, optional: true
|
|
14
|
+
belongs_to :wfh_request, optional: true
|
|
14
15
|
|
|
15
16
|
validates :date, presence: true, uniqueness: { scope: :user_id }
|
|
16
17
|
validates :status, inclusion: { in: STATUSES }
|
|
@@ -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
|
|
|
@@ -76,6 +83,31 @@ module HrLite
|
|
|
76
83
|
retry
|
|
77
84
|
end
|
|
78
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
|
+
|
|
79
111
|
def stamp(record, side)
|
|
80
112
|
record.public_send("#{side}_at=", Time.current)
|
|
81
113
|
record.public_send("#{side}_lat=", @lat)
|
|
@@ -112,6 +144,8 @@ module HrLite
|
|
|
112
144
|
if @lat.nil? || @lng.nil?
|
|
113
145
|
reason = @geo_status == "ok" ? "unavailable" : @geo_status
|
|
114
146
|
record.add_flag!("#{label} without GPS (#{reason})")
|
|
147
|
+
elsif record.wfh
|
|
148
|
+
nil # WFH: off-site is the point
|
|
115
149
|
elsif OfficeLocation.active.exists? && !OfficeLocation.covering?(@lat, @lng)
|
|
116
150
|
office = OfficeLocation.nearest(@lat, @lng)
|
|
117
151
|
distance_km = (Geo.distance_m(office.lat, office.lng, @lat, @lng) / 1000.0).round(1)
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
<% [ [ "In", record.check_in_at, record.check_in_lat, record.check_in_lng ],
|
|
40
40
|
[ "Out", record.check_out_at, record.check_out_lat, record.check_out_lng ] ].each do |label, at, lat, lng| %>
|
|
41
41
|
<% next unless at %>
|
|
42
|
-
<div><%= label %> <%= at.strftime("%H:%M") %> · <%= HrLite::OfficeLocation.place(lat, lng, @offices) || "No GPS" %>
|
|
42
|
+
<div><%= label %> <%= at.strftime("%H:%M") %> · <%= record.wfh ? "WFH" : (HrLite::OfficeLocation.place(lat, lng, @offices) || "No GPS") %>
|
|
43
43
|
<% if lat && lng %>
|
|
44
44
|
· <a class="hrl-card__link" href="https://www.google.com/maps?q=<%= lat %>,<%= lng %>" target="_blank" rel="noopener noreferrer">Map<span class="hrl-sr-only"> of <%= label.downcase %> <%= record.date.strftime("%-d %b") %></span></a>
|
|
45
45
|
<% end %>
|
|
@@ -85,6 +85,9 @@
|
|
|
85
85
|
</div>
|
|
86
86
|
<div class="hrl-field"><%= f.label :buffer_note, "Buffer note (HR only; required for Other)" %><%= f.text_area :buffer_note, rows: 2 %></div>
|
|
87
87
|
<% end %>
|
|
88
|
+
<% if HrLite.config.office_geofence %>
|
|
89
|
+
<div class="hrl-field hrl-field--check"><%= f.check_box :permanent_wfh %><%= f.label :permanent_wfh, "Permanent work from home (checks in from anywhere; inside the office it counts as office)" %></div>
|
|
90
|
+
<% end %>
|
|
88
91
|
<div class="hrl-field">
|
|
89
92
|
<%= f.label :date_of_exit, "Date of exit (leave blank while employed)" %>
|
|
90
93
|
<%= f.date_field :date_of_exit %>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# locals: active (:leaves | :comp_off | :regularizations | :balances) %>
|
|
1
|
+
<%# locals: active (:leaves | :comp_off | :regularizations | :wfh | :balances) %>
|
|
2
2
|
<% tab = ->(key) { tag.attributes(class: [ "hrl-btn", ("hrl-btn--primary" if active == key) ], aria: { current: ("page" if active == key) }) } %>
|
|
3
3
|
<div class="hrl-row hrl-row--mb hrl-row--tabs">
|
|
4
4
|
<a <%= tab.(:leaves) %>
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
<a <%= tab.(:regularizations) %>
|
|
10
10
|
href="<%= hr_lite.admin_regularization_requests_path %>">Regularization
|
|
11
11
|
<% if (n = HrLite::RegularizationRequest.pending.count).positive? %><span class="hrl-badge hrl-badge--warn"><%= n %></span><% end %></a>
|
|
12
|
+
<a <%= tab.(:wfh) %>
|
|
13
|
+
href="<%= hr_lite.admin_wfh_requests_path %>">WFH
|
|
14
|
+
<% if (n = HrLite::WfhRequest.pending.count).positive? %><span class="hrl-badge hrl-badge--warn"><%= n %></span><% end %></a>
|
|
12
15
|
<a <%= tab.(:balances) %>
|
|
13
16
|
href="<%= hr_lite.admin_leave_balances_path %>">Balances</a>
|
|
14
17
|
</div>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<% content_for(:page_title) { "WFH" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Work from home</h1>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<%= render "hr_lite/admin/shared/approvals_tabs", active: :wfh %>
|
|
7
|
+
|
|
8
|
+
<section class="hrl-card">
|
|
9
|
+
<%= form_with url: hr_lite.admin_wfh_requests_path, method: :get, local: true do %>
|
|
10
|
+
<div class="hrl-field"><label for="wfh-from">From</label><%= date_field_tag :from, @from, id: "wfh-from" %></div>
|
|
11
|
+
<div class="hrl-field"><label for="wfh-to">To</label><%= date_field_tag :to, @to, id: "wfh-to" %></div>
|
|
12
|
+
<div class="hrl-field"><label for="wfh-q">Employee name</label><%= search_field_tag :q, params[:q], id: "wfh-q" %></div>
|
|
13
|
+
<% if @departments.any? %>
|
|
14
|
+
<div class="hrl-field"><label for="wfh-dept">Department</label><%= select_tag :department, options_for_select(@departments, params[:department]), include_blank: "All", id: "wfh-dept" %></div>
|
|
15
|
+
<% end %>
|
|
16
|
+
<div class="hrl-field"><label for="wfh-status">Status</label><%= select_tag :status, options_for_select(HrLite::WfhRequest::STATUSES.map { |s| [ s.humanize, s ] }, @status), include_blank: "All", id: "wfh-status" %></div>
|
|
17
|
+
<div class="hrl-field"><label for="wfh-on">Who is home on</label><%= date_field_tag :on, @on, id: "wfh-on" %></div>
|
|
18
|
+
<div class="hrl-field"><label for="wfh-att">WFH attendance</label><%= select_tag :attendance, options_for_select([ [ "Checked in", "checked_in" ], [ "Not checked in", "not_checked_in" ] ], @attendance), include_blank: "All", id: "wfh-att" %></div>
|
|
19
|
+
<div class="hrl-form-actions">
|
|
20
|
+
<button type="submit" class="hrl-btn hrl-btn--primary">Filter</button>
|
|
21
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_wfh_requests_path %>">Reset</a>
|
|
22
|
+
</div>
|
|
23
|
+
<% end %>
|
|
24
|
+
</section>
|
|
25
|
+
|
|
26
|
+
<div class="hrl-kpis">
|
|
27
|
+
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:requested] %></div><div class="hrl-kpi__label">Employees requested</div></div>
|
|
28
|
+
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:approved] %></div><div class="hrl-kpi__label">Employees approved</div></div>
|
|
29
|
+
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:pending] %></div><div class="hrl-kpi__label">Pending requests</div></div>
|
|
30
|
+
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:rejected] %></div><div class="hrl-kpi__label">Rejected requests</div></div>
|
|
31
|
+
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= @home_count %></div><div class="hrl-kpi__label">Home on <%= @on.strftime("%-d %b") %></div></div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<section class="hrl-card">
|
|
35
|
+
<h2 class="hrl-card__title">Working from home on <%= @on.strftime("%a %-d %b") %></h2>
|
|
36
|
+
<% if @home.any? %>
|
|
37
|
+
<% @home.each do |row| %>
|
|
38
|
+
<% rec = @records[row.user.id]
|
|
39
|
+
state, tone = rec ? (rec.wfh ? [ "WFH check-in", "ok" ] : [ "In office", "muted" ]) : [ "Not checked in", "muted" ] %>
|
|
40
|
+
<div class="hrl-feed__item hrl-row">
|
|
41
|
+
<div class="hrl-feed__body"><strong><%= hr_display_name(row.user) %></strong> · <%= row.label %>
|
|
42
|
+
<span class="hrl-badge hrl-badge--<%= tone %>"><%= state %></span></div>
|
|
43
|
+
<% if row.request %>
|
|
44
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_wfh_request_path(row.request) %>">Open<span class="hrl-sr-only"> <%= hr_display_name(row.user) %>'s request</span></a>
|
|
45
|
+
<% end %>
|
|
46
|
+
</div>
|
|
47
|
+
<% end %>
|
|
48
|
+
<% else %>
|
|
49
|
+
<p class="hrl-card--empty">Nobody is working from home on this date.</p>
|
|
50
|
+
<% end %>
|
|
51
|
+
</section>
|
|
52
|
+
|
|
53
|
+
<% if @grantable.any? %>
|
|
54
|
+
<section class="hrl-card">
|
|
55
|
+
<h2 class="hrl-card__title">Give WFH</h2>
|
|
56
|
+
<p class="hrl-small hrl-muted">Approved straight away, up to <%= HrLite::WfhRequest::HR_MAX_DAYS %> days from today or later.</p>
|
|
57
|
+
<%= form_with url: hr_lite.admin_wfh_requests_path, method: :post, local: true do %>
|
|
58
|
+
<div class="hrl-field"><label for="grant-user">Employee</label><%= select_tag :user_id, options_for_select(@grantable.map { |u| [ hr_display_name(u), u.id ] }), include_blank: "Choose one", required: true, id: "grant-user" %></div>
|
|
59
|
+
<div class="hrl-field"><label for="grant-from">From</label><%= date_field_tag :start_date, Date.current, min: Date.current, required: true, id: "grant-from" %></div>
|
|
60
|
+
<div class="hrl-field"><label for="grant-to">To</label><%= date_field_tag :end_date, Date.current, min: Date.current, required: true, id: "grant-to" %></div>
|
|
61
|
+
<div class="hrl-field"><label for="grant-reason">Reason</label><%= text_area_tag :reason, nil, rows: 2, required: true, id: "grant-reason" %></div>
|
|
62
|
+
<div class="hrl-form-actions"><button type="submit" class="hrl-btn hrl-btn--primary">Give WFH</button></div>
|
|
63
|
+
<% end %>
|
|
64
|
+
</section>
|
|
65
|
+
<% end %>
|
|
66
|
+
|
|
67
|
+
<section class="hrl-card">
|
|
68
|
+
<h2 class="hrl-card__title">Schedules <%= @from.strftime("%-d %b") %> – <%= @to.strftime("%-d %b") %></h2>
|
|
69
|
+
<% if @requests.any? %>
|
|
70
|
+
<div class="hrl-table-wrap">
|
|
71
|
+
<table class="hrl-table hrl-table--stack">
|
|
72
|
+
<thead><tr><th>Employee</th><th>Dates</th><th>Reason</th><th>Submitted</th><th>Status</th><th></th></tr></thead>
|
|
73
|
+
<tbody>
|
|
74
|
+
<% @requests.each do |request| %>
|
|
75
|
+
<tr>
|
|
76
|
+
<td data-label="Employee"><%= hr_display_name(request.user) %></td>
|
|
77
|
+
<td data-label="Dates"><%= request.dates_label %> <span class="hrl-small hrl-muted">· <%= pluralize(request.days, "day") %></span></td>
|
|
78
|
+
<td data-label="Reason"><%= truncate(request.reason, length: 80) %></td>
|
|
79
|
+
<td data-label="Submitted"><%= request.created_at.strftime("%-d %b") %></td>
|
|
80
|
+
<td data-label="Status"><%= hrl_request_status_badge(request.status) %></td>
|
|
81
|
+
<td data-label=""><a class="hrl-btn" href="<%= hr_lite.admin_wfh_request_path(request) %>">Open</a></td>
|
|
82
|
+
</tr>
|
|
83
|
+
<% end %>
|
|
84
|
+
</tbody>
|
|
85
|
+
</table>
|
|
86
|
+
</div>
|
|
87
|
+
<%= hrl_pagination %>
|
|
88
|
+
<% else %>
|
|
89
|
+
<p class="hrl-card--empty">No WFH requests match.</p>
|
|
90
|
+
<% end %>
|
|
91
|
+
</section>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<% content_for(:page_title) { "WFH — #{hr_display_name(@request.user)}" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">WFH — <%= hr_display_name(@request.user) %></h1>
|
|
4
|
+
<div class="hrl-page__actions">
|
|
5
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_wfh_requests_path %>">All WFH</a>
|
|
6
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_attendance_path(@request.user_id, month: @request.start_date.strftime("%Y-%m")) %>">Their month</a>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<section class="hrl-card">
|
|
11
|
+
<dl class="hrl-deflist">
|
|
12
|
+
<dt>Dates</dt><dd><%= @request.dates_label %> (<%= pluralize(@request.days, "day") %>)</dd>
|
|
13
|
+
<dt>Reason</dt><dd><%= @request.reason %></dd>
|
|
14
|
+
<dt>Submitted</dt><dd><%= @request.created_at.strftime("%d %b %Y %H:%M") %></dd>
|
|
15
|
+
<dt>Status</dt><dd><%= hrl_request_status_badge(@request.status) %></dd>
|
|
16
|
+
<% if @request.decided_at %>
|
|
17
|
+
<dt>Decided</dt>
|
|
18
|
+
<dd><%= hr_display_name(@request.decided_by) %> · <%= @request.decided_at.strftime("%d %b %H:%M") %>
|
|
19
|
+
<% if @request.decision_note.present? %> · <%= @request.decision_note %><% end %></dd>
|
|
20
|
+
<% end %>
|
|
21
|
+
</dl>
|
|
22
|
+
</section>
|
|
23
|
+
|
|
24
|
+
<% if @request.pending? && @decidable %>
|
|
25
|
+
<%# Two forms: Rails binds the CSRF token to each form's own action. %>
|
|
26
|
+
<section class="hrl-card">
|
|
27
|
+
<h2 class="hrl-card__title">Approve</h2>
|
|
28
|
+
<%= form_with url: hr_lite.approve_admin_wfh_request_path(@request), method: :post, local: true do |f| %>
|
|
29
|
+
<div class="hrl-field"><%= f.label :decision_note, "Remark (optional)", for: "approve_note" %><%= f.text_area :decision_note, id: "approve_note" %></div>
|
|
30
|
+
<div class="hrl-form-actions"><%= f.button "Approve WFH", type: :submit, class: "hrl-btn hrl-btn--primary" %></div>
|
|
31
|
+
<% end %>
|
|
32
|
+
</section>
|
|
33
|
+
<section class="hrl-card">
|
|
34
|
+
<h2 class="hrl-card__title">Reject</h2>
|
|
35
|
+
<%= form_with url: hr_lite.reject_admin_wfh_request_path(@request), method: :post, local: true do |f| %>
|
|
36
|
+
<div class="hrl-field"><%= f.label :decision_note, "Remark (required)", for: "reject_note" %><%= f.text_area :decision_note, id: "reject_note" %></div>
|
|
37
|
+
<div class="hrl-form-actions"><%= f.button "Reject", type: :submit, class: "hrl-btn hrl-btn--danger" %></div>
|
|
38
|
+
<% end %>
|
|
39
|
+
</section>
|
|
40
|
+
<% end %>
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
<% if record&.check_out_at %>
|
|
19
19
|
<span class="hrl-badge hrl-badge--muted">Out <%= record.check_out_at.strftime("%H:%M") %></span>
|
|
20
20
|
<% end %>
|
|
21
|
+
<% if record&.wfh %>
|
|
22
|
+
<span class="hrl-badge hrl-badge--ok">WFH</span>
|
|
23
|
+
<% end %>
|
|
21
24
|
<% if record&.flagged? %>
|
|
22
25
|
<span class="hrl-badge hrl-badge--bad">Flagged</span>
|
|
23
26
|
<% end %>
|
|
@@ -74,6 +77,9 @@
|
|
|
74
77
|
<% end %>
|
|
75
78
|
|
|
76
79
|
<a class="hrl-card__link" href="<%= hr_lite.attendance_path %>">View my month</a>
|
|
80
|
+
<% if HrLite.config.office_geofence %>
|
|
81
|
+
· <a class="hrl-card__link" href="<%= hr_lite.wfh_requests_path %>">Work from home</a>
|
|
82
|
+
<% end %>
|
|
77
83
|
|
|
78
84
|
<% if local_assigns.key?(:attention) %>
|
|
79
85
|
<div class="hrl-row">
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Work from home" } %>
|
|
2
|
+
<% content_for(:back, "Attendance") %>
|
|
3
|
+
<div class="hrl-page__head">
|
|
4
|
+
<h1 class="hrl-page__title">Work from home</h1>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<section class="hrl-card">
|
|
8
|
+
<h2 class="hrl-card__title">Request WFH</h2>
|
|
9
|
+
<p class="hrl-small hrl-muted">Pick dates from today up to <%= HrLite::WfhRequest::MAX_DAYS_AHEAD %> days ahead. HR approves or rejects; only approved dates let you check in away from the office.</p>
|
|
10
|
+
<%= form_with model: @request, url: hr_lite.wfh_requests_path, scope: :wfh_request, local: true do |f| %>
|
|
11
|
+
<%= render "hr_lite/shared/form_errors", record: @request %>
|
|
12
|
+
<% min, max = @window.first.iso8601, @window.last.iso8601 %>
|
|
13
|
+
<div class="hrl-field"><%= f.label :start_date, "From" %><%= f.date_field :start_date, min: min, max: max, required: true %></div>
|
|
14
|
+
<div class="hrl-field"><%= f.label :end_date, "To (same day for one day)" %><%= f.date_field :end_date, min: min, max: max, required: true %></div>
|
|
15
|
+
<div class="hrl-field"><%= f.label :reason, "Why do you need WFH?" %><%= f.text_area :reason, rows: 3, required: true %></div>
|
|
16
|
+
<div class="hrl-form-actions"><%= f.submit "Send to HR", class: "hrl-btn hrl-btn--primary" %></div>
|
|
17
|
+
<% end %>
|
|
18
|
+
</section>
|
|
19
|
+
|
|
20
|
+
<section class="hrl-card">
|
|
21
|
+
<h2 class="hrl-card__title">Next <%= HrLite::WfhRequest::MAX_DAYS_AHEAD %> days</h2>
|
|
22
|
+
<% marks = { "approved" => [ "WFH", "Approved", "present" ], "pending" => [ "Pend", "Pending", "pending" ], "rejected" => [ "Rej", "Rejected", "absent" ] } %>
|
|
23
|
+
<div class="hrl-mgrid" role="group" aria-label="Your WFH dates">
|
|
24
|
+
<% %w[Mo Tu We Th Fr Sa Su].each do |d| %><div class="hrl-mgrid__head"><%= d %></div><% end %>
|
|
25
|
+
<% (@window.first.cwday - 1).times do %><div></div><% end %>
|
|
26
|
+
<% @window.each do |date| %>
|
|
27
|
+
<% code, word, mod = marks[@day_status[date]] %>
|
|
28
|
+
<div class="<%= class_names("hrl-mgrid__day", "hrl-mgrid__day--#{mod}": mod, "hrl-mgrid__day--today": date == Date.current) %>"
|
|
29
|
+
role="group" aria-label="<%= date.strftime('%-d %b') %><%= ", #{word}" if word %>">
|
|
30
|
+
<span class="hrl-mgrid__num"><%= date.day %></span>
|
|
31
|
+
<% if code %><span class="hrl-mgrid__tag"><%= code %></span><% end %>
|
|
32
|
+
</div>
|
|
33
|
+
<% end %>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="hrl-legend hrl-small">
|
|
36
|
+
<% marks.each_value do |_code, word, mod| %>
|
|
37
|
+
<span class="hrl-badge hrl-mgrid__key hrl-mgrid__day--<%= mod %>"><%= word %></span>
|
|
38
|
+
<% end %>
|
|
39
|
+
</div>
|
|
40
|
+
</section>
|
|
41
|
+
|
|
42
|
+
<section class="hrl-card">
|
|
43
|
+
<h2 class="hrl-card__title">My requests</h2>
|
|
44
|
+
<% if @requests.any? %>
|
|
45
|
+
<div class="hrl-table-wrap">
|
|
46
|
+
<table class="hrl-table hrl-table--stack">
|
|
47
|
+
<thead><tr><th>Dates</th><th>Reason</th><th>Status</th><th>HR remarks</th><th></th></tr></thead>
|
|
48
|
+
<tbody>
|
|
49
|
+
<% @requests.each do |request| %>
|
|
50
|
+
<tr>
|
|
51
|
+
<td data-label="Dates"><%= request.dates_label %> <span class="hrl-small hrl-muted">· <%= pluralize(request.days, "day") %></span></td>
|
|
52
|
+
<td data-label="Reason"><%= request.reason %></td>
|
|
53
|
+
<td data-label="Status"><%= hrl_request_status_badge(request.status) %></td>
|
|
54
|
+
<td data-label="HR remarks" class="hrl-small hrl-muted"><%= request.decision_note.presence || "—" %></td>
|
|
55
|
+
<td data-label="" class="<%= 'hrl-cell--void' unless request.pending? %>">
|
|
56
|
+
<% if request.pending? %>
|
|
57
|
+
<%= button_to "Cancel", hr_lite.cancel_wfh_request_path(request), method: :post,
|
|
58
|
+
class: "hrl-btn hrl-btn--danger", form: { data: { turbo_confirm: "Cancel this request?" } } %>
|
|
59
|
+
<% end %>
|
|
60
|
+
</td>
|
|
61
|
+
</tr>
|
|
62
|
+
<% end %>
|
|
63
|
+
</tbody>
|
|
64
|
+
</table>
|
|
65
|
+
</div>
|
|
66
|
+
<%= hrl_pagination %>
|
|
67
|
+
<% else %>
|
|
68
|
+
<p class="hrl-card--empty">No WFH requests yet.</p>
|
|
69
|
+
<% end %>
|
|
70
|
+
</section>
|
data/config/routes.rb
CHANGED
|
@@ -20,6 +20,9 @@ HrLite::Engine.routes.draw do
|
|
|
20
20
|
resources :regularization_requests, only: %i[index new create] do
|
|
21
21
|
member { post :cancel }
|
|
22
22
|
end
|
|
23
|
+
resources :wfh_requests, only: %i[index create] do
|
|
24
|
+
member { post :cancel }
|
|
25
|
+
end
|
|
23
26
|
resources :approvals, only: :index
|
|
24
27
|
resources :expenses, only: %i[index new create] do
|
|
25
28
|
member { post :cancel }
|
|
@@ -74,6 +77,9 @@ HrLite::Engine.routes.draw do
|
|
|
74
77
|
resources :regularization_requests, only: %i[index show] do
|
|
75
78
|
member { post :approve; post :reject }
|
|
76
79
|
end
|
|
80
|
+
resources :wfh_requests, only: %i[index show create] do
|
|
81
|
+
member { post :approve; post :reject }
|
|
82
|
+
end
|
|
77
83
|
resources :roles, except: :show do
|
|
78
84
|
resources :assignments, only: %i[create destroy], controller: "role_assignments"
|
|
79
85
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class CreateHrLiteWfhRequests < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :hr_lite_wfh_requests do |t|
|
|
4
|
+
t.bigint :user_id, null: false
|
|
5
|
+
t.date :start_date, null: false
|
|
6
|
+
t.date :end_date, null: false
|
|
7
|
+
t.text :reason, null: false
|
|
8
|
+
t.string :status, null: false, default: "pending"
|
|
9
|
+
t.bigint :decided_by_id
|
|
10
|
+
t.datetime :decided_at
|
|
11
|
+
t.text :decision_note
|
|
12
|
+
t.bigint :created_by_id
|
|
13
|
+
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
add_index :hr_lite_wfh_requests, [ :user_id, :start_date, :end_date ]
|
|
17
|
+
add_index :hr_lite_wfh_requests, [ :status, :start_date ]
|
|
18
|
+
add_check_constraint :hr_lite_wfh_requests, "status IN ('pending','approved','rejected','cancelled')",
|
|
19
|
+
name: "hr_lite_wfh_requests_status"
|
|
20
|
+
add_check_constraint :hr_lite_wfh_requests, "end_date >= start_date", name: "hr_lite_wfh_requests_range"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddWfhRequestIdToHrLiteAttendanceRecords < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
add_column :hr_lite_attendance_records, :wfh_request_id, :bigint
|
|
4
|
+
add_index :hr_lite_attendance_records, :wfh_request_id
|
|
5
|
+
add_column :hr_lite_attendance_records, :wfh, :boolean, null: false, default: false
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -18,7 +18,9 @@ module HrLite
|
|
|
18
18
|
# true = a punch without GPS is refused (default: recorded and flagged)
|
|
19
19
|
:require_punch_location,
|
|
20
20
|
# true = check-in slots, late penalties and HR buffers (default off)
|
|
21
|
-
:attendance_slots
|
|
21
|
+
:attendance_slots,
|
|
22
|
+
# true = check-in only inside an active office radius, unless WFH is approved for the day
|
|
23
|
+
:office_geofence
|
|
22
24
|
|
|
23
25
|
attr_reader :leave_year_start_month, :slip_release_day, :self_regularization, :work_hours
|
|
24
26
|
|
|
@@ -105,6 +107,7 @@ module HrLite
|
|
|
105
107
|
@self_regularization = nil # e.g. { within_days: 2, per_week: 2 } — employees fix their own missed punch
|
|
106
108
|
@require_punch_location = false
|
|
107
109
|
@attendance_slots = false
|
|
110
|
+
@office_geofence = false
|
|
108
111
|
@work_hours = nil # e.g. { day: 8, probation_day: 9, week: 40 } — short days close as half days
|
|
109
112
|
|
|
110
113
|
# Leadership onboarding/offboarding. onboard_user must return a saved
|
|
@@ -68,7 +68,11 @@ module HrLite
|
|
|
68
68
|
"regularization.requested" => { bell: true, email: false, leadership_email: true, leadership_bell: false },
|
|
69
69
|
"regularization.approved" => { bell: true, email: true, leadership_email: true, leadership_bell: false },
|
|
70
70
|
"regularization.rejected" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
71
|
-
"regularization.cancelled" => { bell: true, email: false, leadership_email: true, leadership_bell: false }
|
|
71
|
+
"regularization.cancelled" => { bell: true, email: false, leadership_email: true, leadership_bell: false },
|
|
72
|
+
"wfh.requested" => { bell: true, email: false, leadership_email: true, leadership_bell: false },
|
|
73
|
+
"wfh.approved" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
74
|
+
"wfh.rejected" => { bell: true, email: true, leadership_email: false, leadership_bell: false },
|
|
75
|
+
"wfh.cancelled" => { bell: true, email: false, leadership_email: false, leadership_bell: false }
|
|
72
76
|
}.freeze
|
|
73
77
|
|
|
74
78
|
class << self
|
data/lib/hr_lite/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.31.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kshitiz sinha
|
|
@@ -115,6 +115,7 @@ files:
|
|
|
115
115
|
- app/controllers/hr_lite/admin/statutory_rate_cards_controller.rb
|
|
116
116
|
- app/controllers/hr_lite/admin/superadmin_controller.rb
|
|
117
117
|
- app/controllers/hr_lite/admin/tax_declarations_controller.rb
|
|
118
|
+
- app/controllers/hr_lite/admin/wfh_requests_controller.rb
|
|
118
119
|
- app/controllers/hr_lite/application_controller.rb
|
|
119
120
|
- app/controllers/hr_lite/appraisals_controller.rb
|
|
120
121
|
- app/controllers/hr_lite/approvals_controller.rb
|
|
@@ -143,6 +144,7 @@ files:
|
|
|
143
144
|
- app/controllers/hr_lite/team_controller.rb
|
|
144
145
|
- app/controllers/hr_lite/timelines_controller.rb
|
|
145
146
|
- app/controllers/hr_lite/users_controller.rb
|
|
147
|
+
- app/controllers/hr_lite/wfh_requests_controller.rb
|
|
146
148
|
- app/helpers/hr_lite/application_helper.rb
|
|
147
149
|
- app/jobs/hr_lite/application_job.rb
|
|
148
150
|
- app/jobs/hr_lite/approval_escalation_job.rb
|
|
@@ -212,6 +214,7 @@ files:
|
|
|
212
214
|
- app/models/hr_lite/statutory_rate_card_record.rb
|
|
213
215
|
- app/models/hr_lite/tax_declaration.rb
|
|
214
216
|
- app/models/hr_lite/tax_declaration_item.rb
|
|
217
|
+
- app/models/hr_lite/wfh_request.rb
|
|
215
218
|
- app/services/hr_lite/access.rb
|
|
216
219
|
- app/services/hr_lite/approval_route.rb
|
|
217
220
|
- app/services/hr_lite/attendance_puncher.rb
|
|
@@ -317,6 +320,8 @@ files:
|
|
|
317
320
|
- app/views/hr_lite/admin/tax_declarations/overview.html.erb
|
|
318
321
|
- app/views/hr_lite/admin/tax_declarations/person.html.erb
|
|
319
322
|
- app/views/hr_lite/admin/tax_declarations/show.html.erb
|
|
323
|
+
- app/views/hr_lite/admin/wfh_requests/index.html.erb
|
|
324
|
+
- app/views/hr_lite/admin/wfh_requests/show.html.erb
|
|
320
325
|
- app/views/hr_lite/appraisals/index.html.erb
|
|
321
326
|
- app/views/hr_lite/appraisals/show.html.erb
|
|
322
327
|
- app/views/hr_lite/approvals/index.html.erb
|
|
@@ -373,6 +378,7 @@ files:
|
|
|
373
378
|
- app/views/hr_lite/team/show.html.erb
|
|
374
379
|
- app/views/hr_lite/timelines/_events.html.erb
|
|
375
380
|
- app/views/hr_lite/timelines/show.html.erb
|
|
381
|
+
- app/views/hr_lite/wfh_requests/index.html.erb
|
|
376
382
|
- app/views/layouts/hr_lite/application.html.erb
|
|
377
383
|
- app/views/layouts/hr_lite/mailer.html.erb
|
|
378
384
|
- app/views/layouts/hr_lite/mailer.text.erb
|
|
@@ -431,6 +437,9 @@ files:
|
|
|
431
437
|
- db/migrate/20261013090000_create_hr_lite_attendance_slots.rb
|
|
432
438
|
- db/migrate/20261013090100_create_hr_lite_attendance_penalties.rb
|
|
433
439
|
- db/migrate/20261013090200_add_buffer_to_hr_lite_employee_profiles.rb
|
|
440
|
+
- db/migrate/20261013100000_create_hr_lite_wfh_requests.rb
|
|
441
|
+
- db/migrate/20261013100100_add_wfh_request_id_to_hr_lite_attendance_records.rb
|
|
442
|
+
- db/migrate/20261013100200_add_permanent_wfh_to_hr_lite_employee_profiles.rb
|
|
434
443
|
- lib/generators/hr_lite/install/install_generator.rb
|
|
435
444
|
- lib/generators/hr_lite/install/templates/AFTER_INSTALL
|
|
436
445
|
- lib/generators/hr_lite/install/templates/initializer.rb
|