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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/app/controllers/hr_lite/admin/attendances_controller.rb +29 -0
  4. data/app/controllers/hr_lite/admin/employees_controller.rb +2 -1
  5. data/app/controllers/hr_lite/admin/wfh_requests_controller.rb +103 -0
  6. data/app/controllers/hr_lite/attendance_controller.rb +23 -0
  7. data/app/controllers/hr_lite/wfh_requests_controller.rb +45 -0
  8. data/app/helpers/hr_lite/application_helper.rb +2 -2
  9. data/app/jobs/hr_lite/attendance_close_job.rb +12 -0
  10. data/app/models/hr_lite/attendance_penalty.rb +30 -0
  11. data/app/models/hr_lite/attendance_record.rb +13 -2
  12. data/app/models/hr_lite/attendance_slot.rb +38 -0
  13. data/app/models/hr_lite/employee_profile.rb +11 -0
  14. data/app/models/hr_lite/wfh_request.rb +108 -0
  15. data/app/services/hr_lite/attendance_puncher.rb +54 -0
  16. data/app/views/hr_lite/admin/attendances/_slot.html.erb +24 -0
  17. data/app/views/hr_lite/admin/attendances/show.html.erb +6 -1
  18. data/app/views/hr_lite/admin/employees/_form.html.erb +14 -0
  19. data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +4 -1
  20. data/app/views/hr_lite/admin/wfh_requests/index.html.erb +91 -0
  21. data/app/views/hr_lite/admin/wfh_requests/show.html.erb +40 -0
  22. data/app/views/hr_lite/attendance/_penalties.html.erb +25 -0
  23. data/app/views/hr_lite/attendance/_punch_card.html.erb +9 -0
  24. data/app/views/hr_lite/attendance/_slot.html.erb +33 -0
  25. data/app/views/hr_lite/attendance/show.html.erb +4 -0
  26. data/app/views/hr_lite/team/show.html.erb +2 -1
  27. data/app/views/hr_lite/wfh_requests/index.html.erb +70 -0
  28. data/config/routes.rb +8 -0
  29. data/db/migrate/20261013090000_create_hr_lite_attendance_slots.rb +16 -0
  30. data/db/migrate/20261013090100_create_hr_lite_attendance_penalties.rb +19 -0
  31. data/db/migrate/20261013090200_add_buffer_to_hr_lite_employee_profiles.rb +7 -0
  32. data/db/migrate/20261013100000_create_hr_lite_wfh_requests.rb +22 -0
  33. data/db/migrate/20261013100100_add_wfh_request_id_to_hr_lite_attendance_records.rb +7 -0
  34. data/db/migrate/20261013100200_add_permanent_wfh_to_hr_lite_employee_profiles.rb +5 -0
  35. data/lib/hr_lite/configuration.rb +7 -1
  36. data/lib/hr_lite/notifications.rb +5 -1
  37. data/lib/hr_lite/version.rb +1 -1
  38. metadata +18 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a5463a93b5739d60f73bd2cd554fce576f754218a8415fbd81ae35bdadc6ebcf
4
- data.tar.gz: dadfaf0eb389d5cbe4fd012ef47d05561d03bc7e313462c491359114bb5cf294
3
+ metadata.gz: cc6f0b0eab85d612fdadaffc907d6b8d64eda04ed9fba81c5611ef37266916cf
4
+ data.tar.gz: 81a344480832392ca76230a465597bc085e6e155b063f7f823fc2348de9891cf
5
5
  SHA512:
6
- metadata.gz: e9d1029c660d36bca33f79ede86ca0684a48616824f243a42df8c8a944b2b1b8fd55401e2cf3fafdf4411e9e33bcca24a9ddbc99c5337ed45db6a0e0830db541
7
- data.tar.gz: 5c7a048e80c6522360eae49ddedf37589762ac548fa4768c5f6d3ff9d2a25be3c5fd8925dcdfdce79ee35daf003c13efd8eb9ecbab29567c83983b87cc5cb70c
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
- # Half days the close job set (not HR); an approved ticket lifts them.
7
- AUTO_NOTES = [ AUTO_CHECKOUT_NOTE, SHORT_DAY_NOTE ].freeze
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)