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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f6f41c24e52098ad42a4af7b5827a3fb2fc09a56e5d46d2194122506faa088b
4
- data.tar.gz: 6c2323f7e9db6bf95db8f28fac7261b7a670dbdb486b902e30809f5331c95a58
3
+ metadata.gz: cc6f0b0eab85d612fdadaffc907d6b8d64eda04ed9fba81c5611ef37266916cf
4
+ data.tar.gz: 81a344480832392ca76230a465597bc085e6e155b063f7f823fc2348de9891cf
5
5
  SHA512:
6
- metadata.gz: c62ee058dcc94b38c24976b2120d7adcb4df515796c92ccb6201b655ea5ee1e54b5b5b8e45c83c34ec811463c50fbc35c651c4d1ae0940317cdb76bf4773bfbc
7
- data.tar.gz: 5d130419f330d6aa715f4c43d36e8d7acf10be7df700930514666600659ab2cb241b70311df969aefbc1ab3e879729680b85b4ea9740311b78b305c4a82a0dc4
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
@@ -0,0 +1,5 @@
1
+ class AddPermanentWfhToHrLiteEmployeeProfiles < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :hr_lite_employee_profiles, :permanent_wfh, :boolean, null: false, default: false
4
+ end
5
+ 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
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.30.0"
2
+ VERSION = "0.31.0"
3
3
  end
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.30.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