hr_lite 0.2.1 → 0.4.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 +96 -0
- data/README.md +2 -1
- data/app/assets/stylesheets/hr_lite/hr_lite.css +35 -4
- data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +38 -0
- data/app/controllers/hr_lite/admin/employees_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/leave_balances_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/leave_types_controller.rb +1 -1
- data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +38 -0
- data/app/controllers/hr_lite/comp_off_requests_controller.rb +46 -0
- data/app/controllers/hr_lite/leave_balances_controller.rb +1 -1
- data/app/controllers/hr_lite/leave_requests_controller.rb +1 -1
- data/app/controllers/hr_lite/org_controller.rb +51 -0
- data/app/controllers/hr_lite/regularization_requests_controller.rb +36 -0
- data/app/controllers/hr_lite/team_controller.rb +11 -0
- data/app/helpers/hr_lite/application_helper.rb +62 -3
- data/app/jobs/hr_lite/leave_year_rollover_job.rb +18 -6
- data/app/models/hr_lite/comp_off_request.rb +177 -0
- data/app/models/hr_lite/employee_profile.rb +55 -0
- data/app/models/hr_lite/leave_balance.rb +43 -10
- data/app/models/hr_lite/leave_request.rb +23 -5
- data/app/models/hr_lite/leave_type.rb +17 -0
- data/app/models/hr_lite/regularization_request.rb +181 -0
- data/app/services/hr_lite/team_day.rb +84 -0
- data/app/views/hr_lite/admin/comp_off_requests/index.html.erb +37 -0
- data/app/views/hr_lite/admin/comp_off_requests/show.html.erb +47 -0
- data/app/views/hr_lite/admin/employees/_form.html.erb +7 -0
- data/app/views/hr_lite/admin/employees/show.html.erb +1 -0
- data/app/views/hr_lite/admin/leave_balances/index.html.erb +1 -1
- data/app/views/hr_lite/admin/leave_requests/index.html.erb +2 -0
- data/app/views/hr_lite/admin/leave_types/_form.html.erb +4 -0
- data/app/views/hr_lite/admin/regularization_requests/index.html.erb +37 -0
- data/app/views/hr_lite/admin/regularization_requests/show.html.erb +50 -0
- data/app/views/hr_lite/admin/shared/_approvals_tabs.html.erb +11 -0
- data/app/views/hr_lite/attendance/show.html.erb +3 -0
- data/app/views/hr_lite/comp_off_requests/index.html.erb +42 -0
- data/app/views/hr_lite/comp_off_requests/new.html.erb +28 -0
- data/app/views/hr_lite/employee_profiles/show.html.erb +3 -0
- data/app/views/hr_lite/leave_balances/index.html.erb +1 -1
- data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +2 -2
- data/app/views/hr_lite/leave_requests/index.html.erb +1 -0
- data/app/views/hr_lite/org/_node.html.erb +14 -0
- data/app/views/hr_lite/org/show.html.erb +38 -0
- data/app/views/hr_lite/regularization_requests/index.html.erb +41 -0
- data/app/views/hr_lite/regularization_requests/new.html.erb +30 -0
- data/app/views/hr_lite/team/show.html.erb +48 -0
- data/app/views/layouts/hr_lite/application.html.erb +1 -1
- data/config/routes.rb +14 -0
- data/db/migrate/20260719104315_add_comp_off_to_hr_lite_leave_types.rb +6 -0
- data/db/migrate/20260719104316_create_hr_lite_comp_off_requests.rb +24 -0
- data/db/migrate/20260719104317_create_hr_lite_regularization_requests.rb +20 -0
- data/db/migrate/20260719162154_add_manager_to_hr_lite_employee_profiles.rb +8 -0
- data/lib/hr_lite/configuration.rb +14 -0
- data/lib/hr_lite/leave_year.rb +33 -0
- data/lib/hr_lite/notifications.rb +14 -2
- data/lib/hr_lite/seeds.rb +16 -1
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +8 -0
- metadata +27 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "I worked on a weekend/holiday — credit me a day off." Approval credits
|
|
3
|
+
# the comp-off leave type's balance (an adjustment, so it shows up in the
|
|
4
|
+
# normal balance chips and can be spent through the ordinary leave flow).
|
|
5
|
+
class CompOffRequest < ApplicationRecord
|
|
6
|
+
STATUSES = %w[pending approved rejected cancelled].freeze
|
|
7
|
+
|
|
8
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
9
|
+
belongs_to :decided_by, class_name: HrLite.config.user_class, optional: true
|
|
10
|
+
|
|
11
|
+
validates :date_worked, :reason, presence: true
|
|
12
|
+
validates :status, inclusion: { in: STATUSES }
|
|
13
|
+
|
|
14
|
+
with_options on: :create do
|
|
15
|
+
validate :date_not_in_future
|
|
16
|
+
validate :date_was_an_off_day
|
|
17
|
+
validate :no_duplicate_for_date
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
after_create :notify_requested
|
|
21
|
+
|
|
22
|
+
scope :pending, -> { where(status: "pending") }
|
|
23
|
+
scope :recent_first, -> { order(date_worked: :desc, id: :desc) }
|
|
24
|
+
|
|
25
|
+
STATUSES.each do |s|
|
|
26
|
+
define_method("#{s}?") { status == s }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def credit_days
|
|
30
|
+
half_day ? BigDecimal("0.5") : BigDecimal("1")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Shown to the approver: proof the person actually punched that day.
|
|
34
|
+
def punch
|
|
35
|
+
AttendanceRecord.find_by(user_id: user_id, date: date_worked)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# --- transitions -------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
# Credits the comp-off balance inside the row lock, so a double-tap
|
|
41
|
+
# cannot credit twice. Fails loudly when no comp-off type is configured
|
|
42
|
+
# or the day stopped being an off day (holiday edited away).
|
|
43
|
+
def approve!(actor:, note: nil)
|
|
44
|
+
type = LeaveType.comp_off_type
|
|
45
|
+
raise MissingCompOffType if type.nil?
|
|
46
|
+
|
|
47
|
+
transition!("approved", actor, note) do
|
|
48
|
+
raise StaleOffDay if WorkingCalendar.new(date_worked..date_worked).working_day?(date_worked)
|
|
49
|
+
|
|
50
|
+
# (A duplicate live request for the same date cannot exist — the
|
|
51
|
+
# partial unique index on [user_id, date_worked] guarantees it.)
|
|
52
|
+
credit_balance!(type)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
Notifications.publish(
|
|
56
|
+
"comp_off.approved",
|
|
57
|
+
title: "Comp-off approved — #{credit_days.to_f} day#{'s' if credit_days > 1} credited for #{date_worked.strftime('%d %b')}",
|
|
58
|
+
body: decision_note.presence,
|
|
59
|
+
path: "/comp_off_requests",
|
|
60
|
+
bell_to: [ user ], email_to: [ user ]
|
|
61
|
+
)
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def reject!(actor:, note:)
|
|
66
|
+
transition!("rejected", actor, note)
|
|
67
|
+
Notifications.publish(
|
|
68
|
+
"comp_off.rejected",
|
|
69
|
+
title: "Comp-off rejected — #{date_worked.strftime('%d %b')}",
|
|
70
|
+
body: decision_note.presence,
|
|
71
|
+
path: "/comp_off_requests",
|
|
72
|
+
bell_to: [ user ], email_to: [ user ]
|
|
73
|
+
)
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def cancel!(actor:)
|
|
78
|
+
transition!("cancelled", actor, nil)
|
|
79
|
+
Notifications.publish(
|
|
80
|
+
"comp_off.cancelled",
|
|
81
|
+
title: "#{HrLite.display_name(user)} cancelled a comp-off request (#{date_worked.strftime('%d %b')})",
|
|
82
|
+
path: "/admin/comp_off_requests",
|
|
83
|
+
bell_to: HrLite.admin_users
|
|
84
|
+
)
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class MissingCompOffType < StandardError
|
|
89
|
+
def message
|
|
90
|
+
"No active leave type is marked as comp-off — enable one under Settings → Leave types first."
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class StaleOffDay < StandardError
|
|
95
|
+
def message
|
|
96
|
+
"That date is now a regular working day (the calendar changed since the request) — " \
|
|
97
|
+
"reject it and ask them to re-file if it still applies."
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# The credit goes into the leave year it can actually be SPENT (the
|
|
104
|
+
# current one at approval time) — crediting date_worked's leave year
|
|
105
|
+
# would strand a year-end day approved after rollover on a dead
|
|
106
|
+
# balance. The balance row is locked for the increment; two admins
|
|
107
|
+
# approving two different requests for one person cannot lose an update.
|
|
108
|
+
def credit_balance!(type)
|
|
109
|
+
year = LeaveYear.current_key
|
|
110
|
+
balance = LeaveBalance.for(user, type, year)
|
|
111
|
+
if balance.new_record?
|
|
112
|
+
begin
|
|
113
|
+
balance.save!
|
|
114
|
+
rescue ActiveRecord::RecordNotUnique
|
|
115
|
+
balance = LeaveBalance.for(user, type, year)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
balance.with_lock do
|
|
119
|
+
balance.adjustment += credit_days
|
|
120
|
+
balance.adjustment_note = [ balance.adjustment_note.presence,
|
|
121
|
+
"+#{credit_days.to_f} comp-off for #{date_worked} (request ##{id})" ].compact.join("; ")
|
|
122
|
+
balance.save!
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def transition!(new_status, actor, note)
|
|
127
|
+
with_lock do
|
|
128
|
+
raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending?
|
|
129
|
+
|
|
130
|
+
yield if block_given?
|
|
131
|
+
self.status = new_status
|
|
132
|
+
self.decided_by_id = actor.id
|
|
133
|
+
self.decided_at = Time.current
|
|
134
|
+
self.decision_note = note
|
|
135
|
+
save!
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def date_not_in_future
|
|
140
|
+
return unless date_worked
|
|
141
|
+
|
|
142
|
+
errors.add(:date_worked, "cannot be in the future") if date_worked > Date.current
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Comp-off is for working on a day you were NOT supposed to — a weekend
|
|
146
|
+
# or a holiday (per the company calendar). Extra hours on a working day
|
|
147
|
+
# are not comp-off under the policy.
|
|
148
|
+
def date_was_an_off_day
|
|
149
|
+
return unless date_worked
|
|
150
|
+
return if date_worked > Date.current
|
|
151
|
+
|
|
152
|
+
calendar = WorkingCalendar.new(date_worked..date_worked)
|
|
153
|
+
if calendar.working_day?(date_worked)
|
|
154
|
+
errors.add(:date_worked, "was a regular working day — comp-off is for working on a weekend or holiday")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def no_duplicate_for_date
|
|
159
|
+
return unless date_worked
|
|
160
|
+
|
|
161
|
+
clash = self.class.where(user_id: user_id, date_worked: date_worked,
|
|
162
|
+
status: %w[pending approved]).where.not(id: id)
|
|
163
|
+
errors.add(:date_worked, "already has a comp-off request") if clash.exists?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def notify_requested
|
|
167
|
+
Notifications.publish(
|
|
168
|
+
"comp_off.requested",
|
|
169
|
+
title: "#{HrLite.display_name(user)} requested comp-off for #{date_worked.strftime('%d %b')} " \
|
|
170
|
+
"(#{half_day ? 'half' : 'full'} day)",
|
|
171
|
+
body: reason.presence,
|
|
172
|
+
path: "/admin/comp_off_requests/#{id}",
|
|
173
|
+
bell_to: HrLite.admin_users
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -8,6 +8,9 @@ module HrLite
|
|
|
8
8
|
include Audited
|
|
9
9
|
|
|
10
10
|
belongs_to :user, class_name: HrLite.config.user_class
|
|
11
|
+
# Reporting line (L1). Optional; the org chart treats manager-less
|
|
12
|
+
# profiles as roots (founders/directors).
|
|
13
|
+
belongs_to :manager, class_name: HrLite.config.user_class, optional: true
|
|
11
14
|
|
|
12
15
|
encrypts :pan_number, :pf_uan, :esi_number, :bank_account_number, :bank_ifsc
|
|
13
16
|
encrypted_money :declared_annual_deductions
|
|
@@ -28,6 +31,8 @@ module HrLite
|
|
|
28
31
|
allow_blank: true
|
|
29
32
|
validates :pf_uan, format: { with: /\A\d{12}\z/, message: "must be 12 digits" }, allow_blank: true
|
|
30
33
|
validate :exit_after_joining
|
|
34
|
+
validate :manager_chain_acyclic
|
|
35
|
+
validate :manager_is_active_staff, if: :manager_id_changed?
|
|
31
36
|
|
|
32
37
|
scope :active_for, ->(month) {
|
|
33
38
|
where(date_of_joining: ..month.end_of_month)
|
|
@@ -44,6 +49,31 @@ module HrLite
|
|
|
44
49
|
from <= to ? (from..to) : nil
|
|
45
50
|
end
|
|
46
51
|
|
|
52
|
+
# The manager as shown to the employee — an exited manager reads as
|
|
53
|
+
# "no manager" until leadership reassigns (matches the org chart).
|
|
54
|
+
def active_manager
|
|
55
|
+
return nil if manager.nil?
|
|
56
|
+
|
|
57
|
+
boss_exit = EmployeeProfile.where(user_id: manager_id).pick(:date_of_exit)
|
|
58
|
+
boss_exit && boss_exit < Date.current ? nil : manager
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# [L1 user, L2 user, ...] walking manager_id upward. Cycle-safe.
|
|
62
|
+
def reporting_chain
|
|
63
|
+
chain = []
|
|
64
|
+
seen = { user_id => true }
|
|
65
|
+
current = manager_id
|
|
66
|
+
while current && !seen[current]
|
|
67
|
+
seen[current] = true
|
|
68
|
+
boss = HrLite.user_klass.find_by(id: current)
|
|
69
|
+
break if boss.nil?
|
|
70
|
+
|
|
71
|
+
chain << boss
|
|
72
|
+
current = EmployeeProfile.where(user_id: boss.id).pick(:manager_id)
|
|
73
|
+
end
|
|
74
|
+
chain
|
|
75
|
+
end
|
|
76
|
+
|
|
47
77
|
def masked_pan
|
|
48
78
|
mask_middle(pan_number)
|
|
49
79
|
end
|
|
@@ -59,6 +89,31 @@ module HrLite
|
|
|
59
89
|
|
|
60
90
|
private
|
|
61
91
|
|
|
92
|
+
# A newly-assigned manager must be a real, still-employed staff member.
|
|
93
|
+
def manager_is_active_staff
|
|
94
|
+
return if manager_id.nil?
|
|
95
|
+
return errors.add(:manager_id, "is not a staff account") if HrLite.user_klass.find_by(id: manager_id).nil?
|
|
96
|
+
|
|
97
|
+
boss_exit = EmployeeProfile.where(user_id: manager_id).pick(:date_of_exit)
|
|
98
|
+
errors.add(:manager_id, "has already exited") if boss_exit && boss_exit < Date.current
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Walking up from the proposed manager must never reach this person.
|
|
102
|
+
def manager_chain_acyclic
|
|
103
|
+
return if manager_id.nil?
|
|
104
|
+
return errors.add(:manager_id, "cannot be yourself") if manager_id == user_id
|
|
105
|
+
|
|
106
|
+
seen = {}
|
|
107
|
+
current = manager_id
|
|
108
|
+
while current
|
|
109
|
+
return errors.add(:manager_id, "creates a reporting loop") if current == user_id
|
|
110
|
+
break if seen[current]
|
|
111
|
+
|
|
112
|
+
seen[current] = true
|
|
113
|
+
current = EmployeeProfile.where(user_id: current).pick(:manager_id)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
62
117
|
def mask_middle(value)
|
|
63
118
|
return nil if value.blank?
|
|
64
119
|
return "•" * value.length if value.length <= 4
|
|
@@ -3,6 +3,11 @@ module HrLite
|
|
|
3
3
|
# entitlement accrues as a pure function of the policy and `used` is
|
|
4
4
|
# recomputed live from approved requests — so a holiday added after an
|
|
5
5
|
# approval self-heals both the quota and payroll.
|
|
6
|
+
#
|
|
7
|
+
# `year` is a LeaveYear key (calendar year by default; with
|
|
8
|
+
# config.leave_year_start_month = 7, key 2026 = Jul 2026 – Jun 2027).
|
|
9
|
+
# Entitlement prorates from the joining date, Keka-style: joined on or
|
|
10
|
+
# before the 15th → that month counts; after the 15th → from next month.
|
|
6
11
|
class LeaveBalance < ApplicationRecord
|
|
7
12
|
belongs_to :user, class_name: HrLite.config.user_class
|
|
8
13
|
belongs_to :leave_type
|
|
@@ -17,26 +22,54 @@ module HrLite
|
|
|
17
22
|
def entitled(as_of: Date.current)
|
|
18
23
|
return Float::INFINITY if leave_type.unlimited?
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
base =
|
|
22
|
-
if leave_type.accrual == "monthly"
|
|
23
|
-
months = as_of.year == year ? as_of.month : (as_of.year > year ? 12 : 0)
|
|
24
|
-
((quota / 12) * months).round(1)
|
|
25
|
-
else
|
|
26
|
-
quota
|
|
27
|
-
end
|
|
28
|
-
base + carried_forward + adjustment
|
|
25
|
+
(accrued_base(as_of) + carried_forward + adjustment).round(1)
|
|
29
26
|
end
|
|
30
27
|
|
|
31
28
|
def used
|
|
29
|
+
range = LeaveYear.range(year)
|
|
32
30
|
requests = LeaveRequest.approved
|
|
33
31
|
.where(user_id: user_id, leave_type_id: leave_type_id)
|
|
34
|
-
.where(start_date:
|
|
32
|
+
.where(start_date: range)
|
|
35
33
|
requests.sum { |request| LeaveDayCounter.count(request) }
|
|
36
34
|
end
|
|
37
35
|
|
|
38
36
|
def available(as_of: Date.current)
|
|
39
37
|
entitled(as_of: as_of) - used
|
|
40
38
|
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Quota earned by as_of, before carry/adjustments. Monthly accrual
|
|
43
|
+
# drips quota/12 per month from the accrual start (leave-year start,
|
|
44
|
+
# or the prorated joining month for mid-year joiners); yearly_upfront
|
|
45
|
+
# grants all months from the accrual start to the year's end at once.
|
|
46
|
+
def accrued_base(as_of)
|
|
47
|
+
range = LeaveYear.range(year)
|
|
48
|
+
start = accrual_start(range)
|
|
49
|
+
return 0 if start.nil?
|
|
50
|
+
|
|
51
|
+
monthly_rate = leave_type.annual_quota / 12
|
|
52
|
+
months =
|
|
53
|
+
if leave_type.accrual == "monthly"
|
|
54
|
+
cap = [ as_of, range.last ].min
|
|
55
|
+
start > cap ? 0 : months_between(start, cap)
|
|
56
|
+
else
|
|
57
|
+
months_between(start, range.last)
|
|
58
|
+
end
|
|
59
|
+
monthly_rate * months
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# nil when the person joins only after this leave year ends.
|
|
63
|
+
def accrual_start(range)
|
|
64
|
+
doj = EmployeeProfile.where(user_id: user_id).pick(:date_of_joining)
|
|
65
|
+
return range.first if doj.nil? || doj <= range.first
|
|
66
|
+
|
|
67
|
+
start = doj.day <= 15 ? doj.beginning_of_month : doj.next_month.beginning_of_month
|
|
68
|
+
start > range.last ? nil : start
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def months_between(from, to)
|
|
72
|
+
(to.year * 12 + to.month) - (from.year * 12 + from.month) + 1
|
|
73
|
+
end
|
|
41
74
|
end
|
|
42
75
|
end
|
|
@@ -11,7 +11,7 @@ module HrLite
|
|
|
11
11
|
|
|
12
12
|
with_options on: :create do
|
|
13
13
|
validate :end_after_start
|
|
14
|
-
validate :
|
|
14
|
+
validate :same_leave_year
|
|
15
15
|
validate :half_day_single_day_only
|
|
16
16
|
validate :consumes_at_least_half_a_day
|
|
17
17
|
validate :no_overlap_with_own_requests
|
|
@@ -52,6 +52,7 @@ module HrLite
|
|
|
52
52
|
return false if insufficient
|
|
53
53
|
|
|
54
54
|
notify_decision("Leave approved")
|
|
55
|
+
notify_team
|
|
55
56
|
true
|
|
56
57
|
end
|
|
57
58
|
|
|
@@ -96,7 +97,7 @@ module HrLite
|
|
|
96
97
|
end
|
|
97
98
|
|
|
98
99
|
def balance
|
|
99
|
-
LeaveBalance.for(user, leave_type, start_date
|
|
100
|
+
LeaveBalance.for(user, leave_type, LeaveYear.key_for(start_date))
|
|
100
101
|
end
|
|
101
102
|
|
|
102
103
|
private
|
|
@@ -132,6 +133,23 @@ module HrLite
|
|
|
132
133
|
)
|
|
133
134
|
end
|
|
134
135
|
|
|
136
|
+
# Everyone should know a colleague will be away — bell + email to the
|
|
137
|
+
# whole team (matrix row "leave.team_notice"; hosts can mute either
|
|
138
|
+
# channel). Deliberately excludes the reason: dates are team-relevant,
|
|
139
|
+
# the why is not.
|
|
140
|
+
def notify_team
|
|
141
|
+
team = HrLite.active_employees.reject { |member| member.id == user_id }
|
|
142
|
+
return if team.empty?
|
|
143
|
+
|
|
144
|
+
Notifications.publish(
|
|
145
|
+
"leave.team_notice",
|
|
146
|
+
title: "#{HrLite.display_name(user)} is on leave — #{leave_type.name} (#{date_range_label})",
|
|
147
|
+
path: "/team",
|
|
148
|
+
bell_to: team,
|
|
149
|
+
email_to: team
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
135
153
|
def notify_decision(title)
|
|
136
154
|
Notifications.publish(
|
|
137
155
|
status == "approved" ? "leave.approved" : "leave.rejected",
|
|
@@ -154,11 +172,11 @@ module HrLite
|
|
|
154
172
|
errors.add(:end_date, "must be on or after the start date") if end_date < start_date
|
|
155
173
|
end
|
|
156
174
|
|
|
157
|
-
def
|
|
175
|
+
def same_leave_year
|
|
158
176
|
return unless start_date && end_date
|
|
159
177
|
|
|
160
|
-
if start_date
|
|
161
|
-
errors.add(:base, "Split requests at the year boundary")
|
|
178
|
+
if LeaveYear.key_for(start_date) != LeaveYear.key_for(end_date)
|
|
179
|
+
errors.add(:base, "Split requests at the leave-year boundary")
|
|
162
180
|
end
|
|
163
181
|
end
|
|
164
182
|
|
|
@@ -12,11 +12,28 @@ module HrLite
|
|
|
12
12
|
validates :accrual, inclusion: { in: ACCRUALS }
|
|
13
13
|
validates :annual_quota, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
|
|
14
14
|
validates :carry_forward_cap, numericality: { greater_than_or_equal_to: 0 }
|
|
15
|
+
validate :single_comp_off_type
|
|
15
16
|
|
|
16
17
|
scope :active, -> { where(active: true).order(:position, :id) }
|
|
17
18
|
|
|
19
|
+
# The type approved comp-off requests credit into (Settings marks it).
|
|
20
|
+
def self.comp_off_type
|
|
21
|
+
active.find_by(comp_off: true)
|
|
22
|
+
end
|
|
23
|
+
|
|
18
24
|
def unlimited?
|
|
19
25
|
annual_quota.nil?
|
|
20
26
|
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# Approvals credit into THE comp-off type — two flagged types would
|
|
31
|
+
# make the credit target position-order roulette.
|
|
32
|
+
def single_comp_off_type
|
|
33
|
+
return unless comp_off
|
|
34
|
+
|
|
35
|
+
clash = self.class.where(comp_off: true).where.not(id: id)
|
|
36
|
+
errors.add(:comp_off, "is already set on #{clash.first.name}") if clash.exists?
|
|
37
|
+
end
|
|
21
38
|
end
|
|
22
39
|
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# "I forgot to punch — please fix that day." The employee proposes the
|
|
3
|
+
# times; an admin approves, which applies them to the attendance record
|
|
4
|
+
# with the full regularization audit trail (same fields the manual admin
|
|
5
|
+
# fix writes), or rejects with a note.
|
|
6
|
+
class RegularizationRequest < ApplicationRecord
|
|
7
|
+
STATUSES = %w[pending approved rejected cancelled].freeze
|
|
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
|
+
|
|
12
|
+
validates :date, :reason, presence: true
|
|
13
|
+
validates :status, inclusion: { in: STATUSES }
|
|
14
|
+
|
|
15
|
+
with_options on: :create do
|
|
16
|
+
validate :date_not_in_future
|
|
17
|
+
validate :at_least_one_time
|
|
18
|
+
validate :times_fall_on_date
|
|
19
|
+
validate :checkout_after_checkin
|
|
20
|
+
validate :no_duplicate_pending
|
|
21
|
+
validate :no_approved_leave_conflict
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
after_create :notify_requested
|
|
25
|
+
|
|
26
|
+
scope :pending, -> { where(status: "pending") }
|
|
27
|
+
scope :recent_first, -> { order(date: :desc, id: :desc) }
|
|
28
|
+
|
|
29
|
+
STATUSES.each do |s|
|
|
30
|
+
define_method("#{s}?") { status == s }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def times_label
|
|
34
|
+
[ check_in_at&.strftime("%H:%M"), check_out_at&.strftime("%H:%M") ].compact.join(" – ").presence || "—"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Current punch state for the approver's context.
|
|
38
|
+
def punch
|
|
39
|
+
AttendanceRecord.find_by(user_id: user_id, date: date)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# --- transitions -------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
# Applies the proposed times to the day's record (creating it if the
|
|
45
|
+
# person never punched at all). Only the provided times overwrite —
|
|
46
|
+
# a forgot-checkout ticket keeps the genuine GPS check-in untouched.
|
|
47
|
+
# A merge that would produce a nonsense record (checkout before the
|
|
48
|
+
# existing check-in, or a checkout with no check-in at all) raises
|
|
49
|
+
# InvalidMerge with the real story so the admin knows what to fix.
|
|
50
|
+
def approve!(actor:, note: nil)
|
|
51
|
+
transition!("approved", actor, note) do
|
|
52
|
+
record = AttendanceRecord.find_or_initialize_by(user_id: user_id, date: date)
|
|
53
|
+
record.check_in_at = check_in_at if check_in_at
|
|
54
|
+
record.check_out_at = check_out_at if check_out_at
|
|
55
|
+
if record.check_in_at.nil?
|
|
56
|
+
raise InvalidMerge, "the day has no check-in — the ticket needs a check-in time too"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
record.status = "present" if record.status.blank?
|
|
60
|
+
record.regularized_by_id = actor.id
|
|
61
|
+
record.regularized_at = Time.current
|
|
62
|
+
record.regularization_note = "Ticket ##{id}: #{reason}"
|
|
63
|
+
raise InvalidMerge, record.errors.full_messages.to_sentence unless record.valid?
|
|
64
|
+
|
|
65
|
+
record.save!
|
|
66
|
+
AuditLog.create!(
|
|
67
|
+
actor: actor, action: "regularize",
|
|
68
|
+
subject_type: record.class.name, subject_id: record.id,
|
|
69
|
+
audited_changes: { "date" => record.date.to_s, "ticket" => id, "note" => reason }
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
Notifications.publish(
|
|
74
|
+
"regularization.approved",
|
|
75
|
+
title: "Attendance fixed for #{date.strftime('%d %b')} (#{times_label})",
|
|
76
|
+
body: decision_note.presence,
|
|
77
|
+
path: "/regularization_requests",
|
|
78
|
+
bell_to: [ user ], email_to: [ user ]
|
|
79
|
+
)
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def reject!(actor:, note:)
|
|
84
|
+
transition!("rejected", actor, note)
|
|
85
|
+
Notifications.publish(
|
|
86
|
+
"regularization.rejected",
|
|
87
|
+
title: "Regularization rejected — #{date.strftime('%d %b')}",
|
|
88
|
+
body: decision_note.presence,
|
|
89
|
+
path: "/regularization_requests",
|
|
90
|
+
bell_to: [ user ], email_to: [ user ]
|
|
91
|
+
)
|
|
92
|
+
true
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def cancel!(actor:)
|
|
96
|
+
transition!("cancelled", actor, nil)
|
|
97
|
+
Notifications.publish(
|
|
98
|
+
"regularization.cancelled",
|
|
99
|
+
title: "#{HrLite.display_name(user)} cancelled a regularization ticket (#{date.strftime('%d %b')})",
|
|
100
|
+
path: "/admin/regularization_requests",
|
|
101
|
+
bell_to: HrLite.admin_users
|
|
102
|
+
)
|
|
103
|
+
true
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The merged attendance record would be invalid — surfaced to the
|
|
107
|
+
# deciding admin verbatim; the ticket stays pending.
|
|
108
|
+
class InvalidMerge < StandardError; end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def transition!(new_status, actor, note)
|
|
113
|
+
with_lock do
|
|
114
|
+
raise ActiveRecord::RecordInvalid.new(self), "not pending" unless pending?
|
|
115
|
+
|
|
116
|
+
yield if block_given?
|
|
117
|
+
self.status = new_status
|
|
118
|
+
self.decided_by_id = actor.id
|
|
119
|
+
self.decided_at = Time.current
|
|
120
|
+
self.decision_note = note
|
|
121
|
+
save!
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def date_not_in_future
|
|
126
|
+
return unless date
|
|
127
|
+
|
|
128
|
+
errors.add(:date, "cannot be in the future") if date > Date.current
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def at_least_one_time
|
|
132
|
+
return if check_in_at || check_out_at
|
|
133
|
+
|
|
134
|
+
errors.add(:base, "Propose a check-in time, a check-out time, or both")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def times_fall_on_date
|
|
138
|
+
return unless date
|
|
139
|
+
|
|
140
|
+
[ [ :check_in_at, check_in_at ], [ :check_out_at, check_out_at ] ].each do |attr, time|
|
|
141
|
+
next unless time
|
|
142
|
+
|
|
143
|
+
errors.add(attr, "must be on #{date.strftime('%d %b')}") unless time.to_date == date
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def checkout_after_checkin
|
|
148
|
+
return unless check_in_at && check_out_at
|
|
149
|
+
return if check_out_at > check_in_at
|
|
150
|
+
|
|
151
|
+
errors.add(:check_out_at, "must be after check-in")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def no_duplicate_pending
|
|
155
|
+
return unless date
|
|
156
|
+
|
|
157
|
+
clash = self.class.pending.where(user_id: user_id, date: date).where.not(id: id)
|
|
158
|
+
errors.add(:date, "already has a pending ticket") if clash.exists?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# A punch on a day covered by approved full-day leave would contradict
|
|
162
|
+
# the leave (which wins in every display and still burns the balance).
|
|
163
|
+
def no_approved_leave_conflict
|
|
164
|
+
return unless date
|
|
165
|
+
|
|
166
|
+
leave = LeaveRequest.active_on(date).where(user_id: user_id, half_day: false)
|
|
167
|
+
errors.add(:date, "is covered by your approved leave — cancel the leave first") if leave.exists?
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def notify_requested
|
|
171
|
+
Notifications.publish(
|
|
172
|
+
"regularization.requested",
|
|
173
|
+
title: "#{HrLite.display_name(user)} raised a regularization ticket for #{date.strftime('%d %b')} " \
|
|
174
|
+
"(#{times_label})",
|
|
175
|
+
body: reason.presence,
|
|
176
|
+
path: "/admin/regularization_requests/#{id}",
|
|
177
|
+
bell_to: HrLite.admin_users
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One row per staff member for a single date — powers the everyone-visible
|
|
3
|
+
# Team board: who's in, who's out, who's on leave, hours worked. Batch
|
|
4
|
+
# queries (five total, regardless of team size); the per-day precedence
|
|
5
|
+
# mirrors DayStatus (holiday > weekend > leave > punch > absent/upcoming).
|
|
6
|
+
class TeamDay
|
|
7
|
+
Row = Struct.new(:user, :profile, :kind, :record, :leave,
|
|
8
|
+
:day_seconds, :month_seconds, :date, keyword_init: true) do
|
|
9
|
+
def date_today?
|
|
10
|
+
date == Date.current
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(date: Date.current)
|
|
15
|
+
@date = date
|
|
16
|
+
@calendar = WorkingCalendar.new(date..date)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :date, :calendar
|
|
20
|
+
|
|
21
|
+
def rows
|
|
22
|
+
@rows ||= build_rows
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def kpis
|
|
26
|
+
{
|
|
27
|
+
checked_in: rows.count { |r| r.record&.check_in_at },
|
|
28
|
+
on_leave: rows.count { |r| %i[leave half_day_leave].include?(r.kind) },
|
|
29
|
+
not_in: rows.count { |r| r.kind == :absent }
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_rows
|
|
36
|
+
users = HrLite.employees
|
|
37
|
+
ids = users.map(&:id)
|
|
38
|
+
profiles = EmployeeProfile.where(user_id: ids).index_by(&:user_id)
|
|
39
|
+
records = AttendanceRecord.for_date(@date).where(user_id: ids).index_by(&:user_id)
|
|
40
|
+
leaves = LeaveRequest.active_on(@date).includes(:leave_type)
|
|
41
|
+
.where(user_id: ids).index_by(&:user_id)
|
|
42
|
+
month_records = AttendanceRecord.for_month(@date).where(user_id: ids).group_by(&:user_id)
|
|
43
|
+
|
|
44
|
+
users.filter_map do |user|
|
|
45
|
+
profile = profiles[user.id]
|
|
46
|
+
next if profile && !profile.active_on?(@date)
|
|
47
|
+
|
|
48
|
+
record = records[user.id]
|
|
49
|
+
leave = leaves[user.id]
|
|
50
|
+
Row.new(
|
|
51
|
+
user: user, profile: profile,
|
|
52
|
+
kind: kind_for(record, leave),
|
|
53
|
+
record: record, leave: leave,
|
|
54
|
+
day_seconds: seconds_worked(record),
|
|
55
|
+
month_seconds: (month_records[user.id] || []).sum { |r| seconds_worked(r) || 0 },
|
|
56
|
+
date: @date
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def kind_for(record, leave)
|
|
62
|
+
return :holiday if @calendar.holiday?(@date)
|
|
63
|
+
return :weekend if @calendar.weekend?(@date)
|
|
64
|
+
return (leave.half_day ? :half_day_leave : :leave) if leave
|
|
65
|
+
|
|
66
|
+
if record&.check_in_at
|
|
67
|
+
record.status.to_sym
|
|
68
|
+
elsif @date > Date.current
|
|
69
|
+
:upcoming
|
|
70
|
+
else
|
|
71
|
+
:absent
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Finished punch → actual duration; still checked in today → running
|
|
76
|
+
# clock, so the board answers "how long have they been in so far".
|
|
77
|
+
def seconds_worked(record)
|
|
78
|
+
return nil unless record&.check_in_at
|
|
79
|
+
return record.worked_duration if record.check_out_at
|
|
80
|
+
|
|
81
|
+
record.date == Date.current ? Time.current - record.check_in_at : nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|